diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aba0f5dc..a4918654 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,17 +14,18 @@ on: jobs: setup: runs-on: ubuntu-20.04 + timeout-minutes: 10 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install clojure tools uses: DeLaGuardo/setup-clojure@3.7 with: lein: 2.9.8 - name: Cache project dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.m2/repository @@ -40,16 +41,17 @@ jobs: set -x case "${GITHUB_EVENT_NAME}" in #scheduled) - # echo '::set-output name=matrix::{"jdk":["8","11","17","21"],"cmd":["test"]}}' + # echo '::set-output name=matrix::{"jdk":["11","17","21"],"cmd":["test"]}}' # ;; *) - echo '::set-output name=matrix::{"jdk":["8","11","17","21"],"cmd":["test"]}}' + echo '::set-output name=matrix::{"jdk":["11","17","21"],"cmd":["test"]}}' ;; esac lint: runs-on: ubuntu-20.04 + timeout-minutes: 10 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install Java uses: actions/setup-java@v3 @@ -70,9 +72,10 @@ jobs: strategy: matrix: ${{fromJson(needs.setup.outputs.matrix)}} runs-on: ubuntu-20.04 + timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Cache project dependencies uses: actions/cache@v3 with: @@ -98,6 +101,7 @@ jobs: CMD: ${{ matrix.cmd }} all-pr-checks: runs-on: ubuntu-20.04 + timeout-minutes: 10 needs: [test, lint] steps: - run: echo "All tests pass!" diff --git a/.gitignore b/.gitignore index b6b063a1..390dfbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ pom.xml.asc /.clj-kondo/.cache /.lsp/.cache /.cpcache/ +/tmp diff --git a/README.md b/README.md index 11a01346..8ed2a554 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,143 @@ to define data types, to annotate them with domain specific information, and to generate artifacts such as schemas, documentation, validators, etc. +## OCSF Schemas + +[flanders.ocsf](src/flanders/ocsf.cljc) creates flanders schemas from OCSF schemas. + +Use `flanders.ocsf/->flanders` to translate an OCSF schema to flanders. The OCSF schemas +are in an internal format returned by urls like https://schema.ocsf.io/api/objects/cve. + +You can export OCSF schemas in bulk at https://schema.ocsf.io/export/schema. +This repository depends on [ocsf-schema-export](https://github.com/threatgrid/ocsf-schema-export) +which provides bulk OCSF schemas exports for each major OCSF version. + +Please add the following library to your classpath (already a dev dep in flanders): + +```clojure +[io.github.threatgrid/ocsf-schema-export "1.0.0-SNAPSHOT"] +``` + +These files are now available on the classpath: + +``` +threatgrid/ocsf-1.0.0-export.json +threatgrid/ocsf-1.1.0-export.json +threatgrid/ocsf-1.2.0-export.json +threatgrid/ocsf-1.3.0-export.json +``` + +Once you choose your version, they can be converted in bulk to flanders using `flanders.ocsf/parse-exported-schemas`. + +```clojure +(require '[flanders.ocsf :as ocsf] + '[cheshire.core :as json] + '[clojure.java.io :as io]) + +(def ocsf-1-3-0-export-json + (-> "threatgrid/ocsf-1.3.0-export.json" io/resource slurp json/decode)) + +(def ocsf-1-3-0-schemas + (ocsf/parse-exported-schemas ocsf-1-3-0-export-json)) +``` + +The result `ocsf-1-3-0-schemas` will have the vals of the `"objects"` and `"classes"` maps to converted to flanders schemas +(and also the `"base-event"` field). + +```clojure +(-> ocsf-1-3-0-schemas + (select-keys ["base_event" "objects" "classes"]) + (update "base_event" class) + (update "objects" update-vals class) + (update "classes" update-vals class) + prn) +;=> {"base_event" flanders.types.MapType, +; "objects" +; {"kill_chain_phase" flanders.types.MapType, +; "sub_technique" flanders.types.MapType, +; "table" flanders.types.MapType, +; ...}, +; "classes" +; {"win/registry_key_query" flanders.types.MapType, +; "datastore_activity" flanders.types.MapType, +; "event_log" flanders.types.MapType, +; ...}} +``` + +From there, you can convert them to other formats such as malli or schema: + +```clojure +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Plumatic Schema +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require '[schema.core :as s] + 'flanders.schema) + +(s/defschema OCSFAuthorizationSchema + (flanders.schema/->schema (get-in ocsf-1-3-0-schemas ["objects" "authorization"]))) + +(s/explain OCSFAuthorizationSchema) +;=> {(optional-key :decision) Str, (optional-key :policy) {Any Any}} + +(meta OCSFAuthorizationSchema) +;=> {:json-schema {:example {:decision "string", :policy {"anything" "anything"}}, +; :description "The Authorization Result object provides details about the authorization outcome and associated policies related to activity."}, +; :name OCSFAuthorizationSchema, :ns user} + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; malli +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require '[malli.core :as m] + 'flanders.malli) + +(def OCSFAuthorizationMalli + (flanders.malli/->malli (get-in ocsf-1-3-0-schemas ["objects" "authorization"]))) + +(m/form OCSFAuthorizationMalli) +;=> [:map +; {:closed true, +; :json-schema/example +; {:decision "string", :policy {"anything" "anything"}}, +; :json-schema/description +; "The Authorization Result object provides details about the authorization outcome and associated policies related to activity."} +; [:decision +; {:json-schema/example "string", +; :optional true, +; :json-schema/description +; "Authorization Result/outcome, e.g. allowed, denied."} +; [:string {:json-schema/example "string"}]] +; [:policy +; {:json-schema/example {"anything" "anything"}, +; :optional true, +; :json-schema/description +; "Details about the Identity/Access management policies that are applicable."} +; [:map +; {:closed true, :json-schema/example {"anything" "anything"}} +; [:malli.core/default +; {:json-schema/example "anything"} +; [:map-of :any [:any {:json-schema/example "anything"}]]]]]] +``` + + +You can also just convert the schemas you need directly from the OCSF schema instead +of via the bulk export: + +```clojure +;; schema +(s/defschema OCSFAuthorization + (flanders.schema/->schema (ocsf/->flanders (get-in ocsf-1-3-0-export ["objects" "authorization"])))) + +;; malli +(def OCSFAuthorization + (flanders.malli/->malli (ocsf/->flanders (get-in ocsf-1-3-0-export ["objects" "authorization"])))) +``` + ## License -Copyright © 2016-2023 Cisco Systems +Copyright © 2016-2025 Cisco Systems Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version. diff --git a/deps.edn b/deps.edn index 3e511b5d..57eea5ac 100644 --- a/deps.edn +++ b/deps.edn @@ -1,13 +1,19 @@ {:paths ["src"] - :deps {metosin/ring-swagger {:mvn/version "0.26.2"} - metosin/schema-tools {:mvn/version "0.13.1"} - org.clojure/clojure {:mvn/version "1.10.1"} + :deps {org.clojure/clojure {:mvn/version "1.12.0"} org.clojure/core.match {:mvn/version "1.0.0"} - prismatic/schema {:mvn/version "1.4.1"}} - :aliases {:test {:extra-paths ["test"] + prismatic/schema {:mvn/version "1.2.0"} + metosin/ring-swagger {:mvn/version "1.0.0"} + metosin/schema-tools {:mvn/version "0.12.3"} + io.github.threatgrid/ocsf-schema-export {:mvn/version "1.0.0-SNAPSHOT"} + org.clojure/math.combinatorics {:mvn/version "0.3.0"}} + :aliases {:test {:extra-paths ["test" "test-resources"] :extra-deps {;; test runner io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"} ;; dev deps - org.clojure/test.check {:mvn/version "1.1.1"}} + org.clojure/test.check {:mvn/version "1.1.1"} + cheshire/cheshire {:mvn/version "5.13.0"} + clj-http/clj-http {:mvn/version "3.13.0"} + babashka/process {:mvn/version "0.5.22"} + metosin/malli {:mvn/version "0.17.0"}} :main-opts ["-m" "cognitect.test-runner"] :exec-fn cognitect.test-runner.api/test}}} diff --git a/project.clj b/project.clj index d67f726e..346893c2 100644 --- a/project.clj +++ b/project.clj @@ -4,13 +4,12 @@ :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :pedantic? :abort - :dependencies [[org.clojure/clojure "1.11.3"] + :dependencies [[org.clojure/clojure "1.12.0"] [org.clojure/core.match "1.0.0"] - [cheshire "5.9.0"] - [prismatic/schema "1.2.0"] [metosin/ring-swagger "1.0.0"] - [metosin/schema-tools "0.12.3"]] + [metosin/schema-tools "0.12.3"] + [org.clojure/math.combinatorics "0.3.0"]] :global-vars {*warn-on-reflection* true} :release-tasks [["clean"] ["vcs" "assert-committed"] @@ -21,7 +20,13 @@ ["change" "version" "leiningen.release/bump-version"] ["vcs" "commit"] ["vcs" "push"]] - + :resource-paths ["resources"] :profiles {:dev - {:dependencies [[org.clojure/test.check "1.1.1"] - [metosin/malli "0.13.0"]]}}) + {:resource-paths ["test-resources"] + :dependencies [[org.clojure/test.check "1.1.1"] + [babashka/process "0.5.22"] + [cheshire "5.13.0"] + [clj-http "3.13.0"] + [potemkin "0.4.7"] + [metosin/malli "0.17.0"] + [io.github.threatgrid/ocsf-schema-export "1.0.0-SNAPSHOT"]]}}) diff --git a/src/flanders/malli.clj b/src/flanders/malli.clj index eb682e96..024b50f1 100644 --- a/src/flanders/malli.clj +++ b/src/flanders/malli.clj @@ -79,7 +79,10 @@ MapType (->malli' [{:keys [entries key?] :as dll} opts] (let [f #(->malli' % opts) - s (-> (into [:merge] (map (fn [e] [:map (f e)])) entries) + s (-> (case (count entries) + ;workaround https://github.com/metosin/malli/pull/1147 + 0 [:merge [:map]] + (into [:merge] (map (fn [e] (m/schema [:map (f e)] opts))) entries)) (m/schema opts) m/deref ;; eliminate :merge (mu/update-properties assoc :closed true) diff --git a/src/flanders/ocsf.cljc b/src/flanders/ocsf.cljc new file mode 100644 index 00000000..d81cc237 --- /dev/null +++ b/src/flanders/ocsf.cljc @@ -0,0 +1,76 @@ +(ns flanders.ocsf + (:require [flanders.core :as f] + #?(:clj flanders.types + :cljs [flanders.types :refer [IntegerType NumberType StringType]])) + #?(:clj (:import [flanders.types IntegerType NumberType StringType]))) + +;; :caption => title +;; all maps are closed +;; :observable => seems to be a class id +(defn parse-attribute [[k {:strs [description requirement enum type is_array caption]}] _opts] + (let [f (case type + "string_t" (f/str) + "integer_t" (f/int) + "long_t" (f/int) + "float_t" (f/num) + "uuid_t" (f/str) + "boolean_t" (f/bool) + "port_t" (f/int) + "file_hash_t" (f/str) + "file_name_t" (f/str) + "process_name_t" (f/str) + "username_t" (f/str) + "timestamp_t" (f/int) + "user" (f/str) + "account" (f/str) + "actor" (f/str) + "affected_code" (f/str) + "url_t" (f/str) + "datetime_t" (f/str) + "object_t" (f/map [(f/entry f/any f/any :required? false)]) + "hostname_t" (f/str) + "ip_t" (f/str) + "mac_t" (f/str) + "subnet_t" (f/str) + "email_t" (f/str) + ("json_t" nil) f/any) + f (if enum + (cond + (instance? IntegerType f) (assoc f :values (mapv #?(:clj Long/parseLong :cljs parse-long) (keys enum))) + (instance? NumberType f) (assoc f :values (mapv #?(:clj Double/parseDouble :cljs parse-double) (keys enum))) + (instance? StringType f) (assoc f :values (mapv str (keys enum))) + :else (throw (ex-info (str "enum on " (type f)) {}))) + f) + f (cond-> f + is_array f/seq-of)] + (f/entry (keyword k) f + :description (or description caption) + :required? (case requirement + "required" true + ("recommended" "optional" nil) false)))) + +(defn- normalize-attributes [attributes] + (into (sorted-map) (if (map? attributes) [attributes] attributes))) + +(defn ->flanders + "Converts parsed OCSF schemas to Flanders." + ([v] (->flanders v nil)) + ([{:strs [attributes description]} opts] + (-> (f/map (mapv #(parse-attribute % opts) (normalize-attributes attributes))) + (assoc :description description)))) + +(defn parse-exported-schemas + "Takes the result of https://schema.ocsf.io/export/schema parsed as edn + and updates the base_event, objects and classes schemas to flanders. + + Flanders includes a dependency on https://github.com/frenchy64/ocsf-schema-export + which provides OCSF schemas on the classpath. + + Example: + (parse-exported-schemas (-> \"threatgrid/ocsf-1.3.0-export.json\" io/resource slurp json/decode))" + ([export] (parse-exported-schemas export nil)) + ([export opts] + (-> export + (update "base_event" ->flanders opts) + (update "objects" update-vals #(->flanders % opts)) + (update "classes" update-vals #(->flanders % opts))))) diff --git a/src/flanders/schema.cljc b/src/flanders/schema.cljc index e7a51572..dcddc04e 100644 --- a/src/flanders/schema.cljc +++ b/src/flanders/schema.cljc @@ -10,7 +10,7 @@ SequenceOfType SetOfType SignatureType StringType]]) #?(:clj [ring.swagger.json-schema :as rs]) [flanders.predicates :as fp] - [flanders.example :as example] + #?(:clj [flanders.example :as example]) [flanders.protocols :as prots] [schema-tools.core :as st] [schema.core :as s]) @@ -42,7 +42,7 @@ (def get-schema (memoize ->schema)) -(defn- describe [schema dll] +(defn- describe [schema #?(:cljs _dll :clj dll)] #?(:cljs schema :clj (rs/field schema diff --git a/test-resources/flanders_test/cisco_network_activity.json b/test-resources/flanders_test/cisco_network_activity.json new file mode 100644 index 00000000..7bf5ee7f --- /dev/null +++ b/test-resources/flanders_test/cisco_network_activity.json @@ -0,0 +1,531 @@ +{ + "attributes": [ + { + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "_source": "base_event", + "caption": "Unmapped Data", + "object_type": "object", + "object_name": "Object" + } + }, + { + "raw_data": { + "type": "string_t", + "description": "The event data as received from the event source.", + "group": "context", + "requirement": "optional", + "_source": "base_event", + "caption": "Raw Data", + "type_name": "String" + } + }, + { + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event. For example add location information for the IP address in the DNS answers:\u003C/p\u003E\u003Ccode\u003E[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]\u003C/code\u003E", + "group": "context", + "is_array": true, + "requirement": "optional", + "_source": "base_event", + "caption": "Enrichments", + "object_type": "enrichment", + "object_name": "Enrichment" + } + }, + { + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event \u003Ccode\u003Etime\u003C/code\u003E is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "_source": "base_event", + "caption": "Timezone Offset", + "type_name": "Integer" + } + }, + { + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "_source": "base_event", + "caption": "End Time", + "type_name": "Timestamp" + } + }, + { + "class_uid": { + "default": 0, + "type": "integer_t", + "enum": { + "4001": { + "description": "Network Activity events report network connection and traffic activity.", + "caption": "Network Activity" + } + }, + "description": "The unique identifier of a class. A Class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "_source": "network_activity", + "caption": "Class ID", + "type_name": "Integer", + "sibling": "class_name" + } + }, + { + "activity_id": { + "attributes": [], + "type": "integer_t", + "enum": { + "3": { + "description": "The network connection was abnormally terminated or closed by a middle device like firewalls.", + "caption": "Reset" + }, + "6": { + "description": "Network traffic report.", + "caption": "Traffic" + }, + "99": { + "description": "The event activity is not mapped.", + "caption": "Other" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A new network connection was opened.", + "caption": "Open" + }, + "2": { + "description": "The network connection was closed.", + "caption": "Close" + }, + "4": { + "description": "The network connection failed. For example a connection timeout or no route to host.", + "caption": "Fail" + }, + "5": { + "description": "The network connection was refused. For example an attempt to connect to a server port which is not open.", + "caption": "Refuse" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "_source": "network_activity", + "caption": "Activity ID", + "type_name": "Integer", + "sibling": "activity_name" + } + }, + { + "status_detail": { + "type": "string_t", + "description": "The status details contains additional information about the event outcome.", + "group": "primary", + "requirement": "optional", + "_source": "base_event", + "caption": "Status Details", + "type_name": "String" + } + }, + { + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: \u003Ccode\u003ENetwork Activity\u003C/code\u003E.", + "group": "classification", + "requirement": "optional", + "_source": "base_event", + "caption": "Class", + "type_name": "String" + } + }, + { + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "_source": "base_event", + "caption": "Activity", + "type_name": "String" + } + }, + { + "app_name": { + "type": "string_t", + "description": "The name of the application that is associated with the event or object.", + "group": "context", + "requirement": "optional", + "_source": "network_activity", + "caption": "Application Name", + "type_name": "String" + } + }, + { + "count": { + "default": 1, + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event \u003Cstrong\u003EStart Time\u003C/strong\u003E to \u003Cstrong\u003EEnd Time\u003C/strong\u003E period.", + "group": "occurrence", + "requirement": "optional", + "_source": "base_event", + "caption": "Count", + "type_name": "Integer" + } + }, + { + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "_source": "base_event", + "caption": "Status", + "type_name": "String" + } + }, + { + "dst_endpoint": { + "type": "object_t", + "description": "The responder (server) in a network connection.", + "group": "primary", + "requirement": "required", + "_source": "network_activity", + "caption": "Destination Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + } + }, + { + "message": { + "type": "string_t", + "description": "The description of the event, as defined by the event source.", + "group": "primary", + "requirement": "recommended", + "_source": "base_event", + "caption": "Message", + "type_name": "String" + } + }, + { + "status_id": { + "type": "integer_t", + "enum": { + "99": { + "description": "The event status is not mapped. See the \u003Ccode\u003Estatus\u003C/code\u003E attribute, which contains a data source specific value.", + "caption": "Other" + }, + "0": { + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "_source": "base_event", + "caption": "Status ID", + "type_name": "Integer", + "sibling": "status" + } + }, + { + "category_uid": { + "default": 0, + "type": "integer_t", + "enum": { + "4": { + "description": "Network Activity events.", + "uid": 4, + "caption": "Network Activity" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "_source": "network_activity", + "caption": "Category ID", + "type_name": "Integer", + "sibling": "category_name" + } + }, + { + "type_name": { + "type": "string_t", + "description": "The event type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "_source": "base_event", + "caption": "Type Name", + "type_name": "String" + } + }, + { + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event.", + "group": "context", + "requirement": "required", + "_source": "base_event", + "caption": "Metadata", + "object_type": "metadata", + "object_name": "Metadata" + } + }, + { + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.\u003Cbr /\u003E\u003Cbr /\u003EFor example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "optional", + "_source": "base_event", + "caption": "Status Code", + "type_name": "String" + } + }, + { + "proxy": { + "type": "object_t", + "description": "If a proxy connection is present, the connection from the client to the proxy server.", + "group": "primary", + "requirement": "optional", + "_source": "network_activity", + "caption": "Proxy", + "object_type": "network_proxy", + "object_name": "Network Proxy Endpoint" + } + }, + { + "observables": { + "type": "object_t", + "description": "The observables associated with the event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "_source": "base_event", + "caption": "Observables", + "object_type": "observable", + "object_name": "Observable" + } + }, + { + "connection_info": { + "type": "object_t", + "description": "The network connection information.", + "group": "primary", + "requirement": "recommended", + "_source": "network_activity", + "caption": "Connection Info", + "object_type": "network_connection_info", + "object_name": "Network Connection Information" + } + }, + { + "severity": { + "type": "string_t", + "description": "The event severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the event source.", + "group": "classification", + "requirement": "optional", + "_source": "base_event", + "caption": "Severity", + "type_name": "String" + } + }, + { + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time.", + "group": "occurrence", + "requirement": "required", + "_source": "base_event", + "caption": "Event Time", + "type_name": "Timestamp" + } + }, + { + "src_endpoint": { + "type": "object_t", + "description": "The initiator (client) of the network connection.", + "group": "primary", + "requirement": "required", + "_source": "network_activity", + "caption": "Source Endpoint", + "object_type": "network_endpoint", + "object_name": "Network Endpoint" + } + }, + { + "tls": { + "type": "object_t", + "description": "The Transport Layer Security (TLS) attributes.", + "group": "primary", + "requirement": "optional", + "_source": "network_activity", + "caption": "TLS", + "object_type": "tls", + "object_name": "Transport Layer Security (TLS)" + } + }, + { + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: \u003Ccode\u003ENetwork Activity\u003C/code\u003E.", + "group": "classification", + "requirement": "optional", + "_source": "base_event", + "caption": "Category", + "type_name": "String" + } + }, + { + "duration": { + "type": "integer_t", + "description": "The event duration or aggregate time, the amount of time the event covers from \u003Ccode\u003Estart_time\u003C/code\u003E to \u003Ccode\u003Eend_time\u003C/code\u003E in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "_source": "base_event", + "caption": "Duration", + "type_name": "Integer" + } + }, + { + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "_source": "base_event", + "caption": "Start Time", + "type_name": "Timestamp" + } + }, + { + "type_uid": { + "type": "integer_t", + "enum": { + "400103": { + "description": "The network connection was abnormally terminated or closed by a middle device like firewalls.", + "caption": "Network Activity: Reset" + }, + "400106": { + "description": "Network traffic report.", + "caption": "Network Activity: Traffic" + }, + "400199": { + "caption": "Network Activity: Other" + }, + "400100": { + "caption": "Network Activity: Unknown" + }, + "400101": { + "description": "A new network connection was opened.", + "caption": "Network Activity: Open" + }, + "400102": { + "description": "The network connection was closed.", + "caption": "Network Activity: Close" + }, + "400104": { + "description": "The network connection failed. For example a connection timeout or no route to host.", + "caption": "Network Activity: Fail" + }, + "400105": { + "description": "The network connection was refused. For example an attempt to connect to a server port which is not open.", + "caption": "Network Activity: Refuse" + } + }, + "description": "The event type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: \u003Ccode\u003Eclass_uid * 100 + activity_id\u003C/code\u003E.", + "group": "classification", + "requirement": "required", + "_source": "network_activity", + "caption": "Type ID", + "type_name": "Integer", + "sibling": "type_name" + } + }, + { + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "99": { + "description": "The event severity is not mapped. See the \u003Ccode\u003Eseverity\u003C/code\u003E attribute, which contains a data source specific value.", + "caption": "Other" + }, + "0": { + "description": "The event severity is not known.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "\u003Cp\u003EThe normalized identifier of the event severity.\u003C/p\u003EThe normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "_source": "base_event", + "caption": "Severity ID", + "type_name": "Integer", + "sibling": "severity" + } + }, + { + "traffic": { + "type": "object_t", + "description": "The network traffic refers to the amount of data moving across a network at a given point of time. Intended to be used alongside Network Connection.", + "group": "primary", + "requirement": "optional", + "_source": "network_activity", + "caption": "Traffic", + "object_type": "network_traffic", + "object_name": "Network Traffic" + } + } + ], + "name": "network_activity", + "description": "Network Activity events report network connection and traffic activity.", + "uid": 4001, + "extends": "base_event", + "category": "network", + "profiles": [ + "cloud", + "datetime", + "host", + "security_control", + "container" + ], + "caption": "Network Activity", + "category_name": "Network Activity" +} \ No newline at end of file diff --git a/test/flanders/ocsf_test.clj b/test/flanders/ocsf_test.clj new file mode 100644 index 00000000..932876da --- /dev/null +++ b/test/flanders/ocsf_test.clj @@ -0,0 +1,386 @@ +(ns flanders.ocsf-test + (:refer-clojure :exclude [prn println]) + (:require [babashka.process :as proc] + [cheshire.core :as json] + [clj-http.client :as client] + [clojure.java.io :as io] + [clojure.test :refer [deftest is testing]] + [clojure.walk :as walk] + [flanders.malli :as malli] + [flanders.ocsf :as ocsf] + [flanders.schema :as schema] + [malli.core :as m] + [schema.core :as s])) + +(defn prn [& args] (locking prn (apply clojure.core/prn args))) +(defn println [& args] (locking prn (apply clojure.core/println args))) + +(defn sort-recursive [v] + (walk/postwalk + (fn [v] + (cond->> v + (map? v) (into (sorted-map)))) + v)) + +(defn ocsf-server-down [] + (println "docker down ocsf-server") + (proc/shell {:dir "tmp/ocsf-server" + :out *out* + :err *err*} + "docker" "compose" "down")) + +(defn ocsf-server-up [commit] + (ocsf-server-down) + (println "resetting ocsf-schema repo") + (proc/shell {:dir "tmp/ocsf-schema" + :out *out* + :err *err*} + "git" "reset" "--hard" commit) + (println "docker up ocsf-server") + (try (proc/shell {:dir "tmp/ocsf-server" + :out *out* + :err *err*} + "docker" "compose" "up" "--wait") + (catch Exception e + ;;?? ocsf-server-ocsf-elixir-1 exited with code 0 + (prn e))) + (reduce + (fn [_ _] + (Thread/sleep 500) + (try (-> "http://localhost:8080/" client/get) + (reduced true) + (catch Exception _))) + nil (range 10))) + +(defn assert-map! [m] (assert (map? m)) m) + +(defn gen-ocsf-schema-samples + [{:keys [version base-url nsamples]}] + (let [export-schema (-> (doto (format "threatgrid/ocsf-%s-export.json" version) prn) io/resource slurp json/decode) + sample {"objects" (into {} (map (fn [name] + (when (Thread/interrupted) (throw (InterruptedException.))) + [name (doall + (pmap (fn [_] + (when (Thread/interrupted) (throw (InterruptedException.))) + (let [url (str base-url "sample/objects/" name)] + ;(prn url) + (try (-> url client/get :body json/decode assert-map!) + (catch Exception e + (prn url) + (throw e))))) + (range nsamples)))])) + (keys (get export-schema "objects"))) + "base_event" (doall + (pmap (fn [_] + (when (Thread/interrupted) (throw (InterruptedException.))) + (let [url (str base-url "sample/base_event")] + ;(prn url) + (try (-> url client/get :body json/decode assert-map!) + (catch Exception e + (prn url) + (throw e))))) + (range nsamples))) + "classes" (into {} (map (fn [name] + (when (Thread/interrupted) (throw (InterruptedException.))) + [name (doall (pmap (fn [_] + (when (Thread/interrupted) (throw (InterruptedException.))) + (let [url (str base-url "sample/classes/" name)] + ;(prn url) + (try (-> url client/get :body json/decode assert-map!) + (catch Exception e + (prn url) + (throw e))))) + (range nsamples)))])) + (keys (get export-schema "classes")))}] + (spit (doto (format "tmp/flanders/ocsf-%s-sample.json" version) + io/make-parents) + (-> sample + sort-recursive + (json/encode {:pretty true}))))) + +(defn prep-ocsf-repos [] + (doseq [repo ["ocsf-server" "ocsf-schema"] + :let [tmp-dir "tmp" + dir (str tmp-dir "/" repo)]] + (if (.exists (io/file dir)) + (proc/shell {:dir dir + :out *out* + :err *err*} + "git" "fetch" "--all") + (do (proc/shell {:out *out* + :err *err*} + "mkdir" "-p" tmp-dir) + (proc/shell {:dir tmp-dir + :out *out* + :err *err*} + "git" "clone" (format "https://github.com/ocsf/%s.git" repo)))))) + +(def all-ocsf-exports + [;; not included in https://github.com/frenchy64/ocsf-schema-export + #_{:version "1.4.0-dev" + :nsamples 10 + :nobjects 141 + :nclasses 78 + :ocsf-schema "origin/main"} + {:version "1.3.0" + :nsamples 10 + :nobjects 121 + :nclasses 72 + :ocsf-schema "1.3.0"} + {:version "1.2.0" + :nsamples 10 + :nobjects 111 + :nclasses 65 + :ocsf-schema "v1.2.0"} + {:version "1.1.0" + :nsamples 10 + :nobjects 106 + :nclasses 50 + :ocsf-schema "v1.1.0"} + {:version "1.0.0" + :nsamples 10 + :nobjects 84 + :nclasses 36 + :ocsf-schema "v1.0.0"}]) + +(defn gen-ocsf-samples [] + (prep-ocsf-repos) + (try (doseq [m all-ocsf-exports] + (ocsf-server-up (:ocsf-schema m)) + (gen-ocsf-schema-samples (assoc m :base-url "http://localhost:8080/")) + (ocsf-server-down)) + (finally + (ocsf-server-down)))) + +(def ocsf-1-3-0-export (delay (json/decode (slurp (io/resource "threatgrid/ocsf-1.3.0-export.json"))))) + +(deftest flanders-test + (is (= [:map + {:closed true, + :json-schema/example + {:cvss [{"anything" "anything"}], + :desc "string", + :cwe {"anything" "anything"}, + :uid "string", + :epss {"anything" "anything"}, + :created_time 10, + :type "string", + :cwe_url "string", + :references ["string"], + :title "string", + :product {"anything" "anything"}, + :modified_time 10, + :created_time_dt "string", + :cwe_uid "string", + :modified_time_dt "string"}, + :json-schema/description + "The Common Vulnerabilities and Exposures (CVE) object represents publicly disclosed cybersecurity vulnerabilities defined in CVE Program catalog (CVE). There is one CVE Record for each vulnerability in the catalog."} + [:created_time + {:json-schema/example 10, + :optional true, + :json-schema/description + "The Record Creation Date identifies when the CVE ID was issued to a CVE Numbering Authority (CNA) or the CVE Record was published on the CVE List. Note that the Record Creation Date does not necessarily indicate when this vulnerability was discovered, shared with the affected vendor, publicly disclosed, or updated in CVE."} + [:int #:json-schema{:example 10}]] + [:created_time_dt + {:json-schema/example "string", + :optional true, + :json-schema/description + "The Record Creation Date identifies when the CVE ID was issued to a CVE Numbering Authority (CNA) or the CVE Record was published on the CVE List. Note that the Record Creation Date does not necessarily indicate when this vulnerability was discovered, shared with the affected vendor, publicly disclosed, or updated in CVE."} + [:string #:json-schema{:example "string"}]] + [:cvss + {:json-schema/example [{"anything" "anything"}], + :optional true, + :json-schema/description + "The CVSS object details Common Vulnerability Scoring System (CVSS) scores from the advisory that are related to the vulnerability."} + [:sequential + [:map + {:closed true, :json-schema/example {"anything" "anything"}} + [:malli.core/default + #:json-schema{:example "anything"} + [:map-of :any [:any #:json-schema{:example "anything"}]]]]]] + [:cwe + {:json-schema/example {"anything" "anything"}, + :optional true, + :json-schema/description + "The CWE object represents a weakness in a software system that can be exploited by a threat actor to perform an attack. The CWE object is based on the Common Weakness Enumeration (CWE) catalog."} + [:map + {:closed true, :json-schema/example {"anything" "anything"}} + [:malli.core/default + #:json-schema{:example "anything"} + [:map-of :any [:any #:json-schema{:example "anything"}]]]]] + [:cwe_uid + {:json-schema/example "string", + :optional true, + :json-schema/description + "The Common Weakness Enumeration (CWE) unique identifier. For example: CWE-787."} + [:string #:json-schema{:example "string"}]] + [:cwe_url + {:json-schema/example "string", + :optional true, + :json-schema/description + "Common Weakness Enumeration (CWE) definition URL. For example: https://cwe.mitre.org/data/definitions/787.html."} + [:string #:json-schema{:example "string"}]] + [:desc + {:json-schema/example "string", + :optional true, + :json-schema/description "A brief description of the CVE Record."} + [:string #:json-schema{:example "string"}]] + [:epss + {:json-schema/example {"anything" "anything"}, + :optional true, + :json-schema/description + "The Exploit Prediction Scoring System (EPSS) object describes the estimated probability a vulnerability will be exploited. EPSS is a community-driven effort to combine descriptive information about vulnerabilities (CVEs) with evidence of actual exploitation in-the-wild. (EPSS)."} + [:map + {:closed true, :json-schema/example {"anything" "anything"}} + [:malli.core/default + #:json-schema{:example "anything"} + [:map-of :any [:any #:json-schema{:example "anything"}]]]]] + [:modified_time + {:json-schema/example 10, + :optional true, + :json-schema/description + "The Record Modified Date identifies when the CVE record was last updated."} + [:int #:json-schema{:example 10}]] + [:modified_time_dt + {:json-schema/example "string", + :optional true, + :json-schema/description + "The Record Modified Date identifies when the CVE record was last updated."} + [:string #:json-schema{:example "string"}]] + [:product + {:json-schema/example {"anything" "anything"}, + :optional true, + :json-schema/description + "The product where the vulnerability was discovered."} + [:map + {:closed true, :json-schema/example {"anything" "anything"}} + [:malli.core/default + #:json-schema{:example "anything"} + [:map-of :any [:any #:json-schema{:example "anything"}]]]]] + [:references + {:json-schema/example ["string"], + :optional true, + :json-schema/description + "A list of reference URLs with additional information about the CVE Record."} + [:sequential [:string #:json-schema{:example "string"}]]] + [:title + {:json-schema/example "string", + :optional true, + :json-schema/description + "A title or a brief phrase summarizing the CVE record."} + [:string #:json-schema{:example "string"}]] + [:type + {:json-schema/example "string", + :optional true, + :json-schema/description + "

The vulnerability type as selected from a large dropdown menu during CVE refinement.

Most frequently used vulnerability types are: DoS, Code Execution, Overflow, Memory Corruption, Sql Injection, XSS, Directory Traversal, Http Response Splitting, Bypass something, Gain Information, Gain Privileges, CSRF, File Inclusion. For more information see Vulnerabilities By Type distributions."} + [:string #:json-schema{:example "string"}]] + [:uid + #:json-schema{:example "string", + :description + "The Common Vulnerabilities and Exposures unique number assigned to a specific computer vulnerability. A CVE Identifier begins with 4 digits representing the year followed by a sequence of digits that acts as a unique identifier. For example: CVE-2021-12345."} + [:string #:json-schema{:example "string"}]]] + (m/form (malli/->malli (ocsf/->flanders (get-in @ocsf-1-3-0-export ["objects" "cve"])))))) + (is (= '{(optional-key :comment) Str, + :severity_id Int, + :category_uid Int, + (optional-key :remediation) {Any Any}, + (optional-key :status_code) Str, + (optional-key :message) Str, + (optional-key :count) Int, + (optional-key :start_time_dt) Str, + (optional-key :end_time) Int, + :osint [{Any Any}], + :type_uid Int, + (optional-key :resources) [{Any Any}], + :cloud {Any Any}, + :time Int, + (optional-key :status) Str, + (optional-key :observables) [{Any Any}], + (optional-key :api) {Any Any}, + (optional-key :duration) Int, + :class_uid Int, + (optional-key :confidence) Str, + (optional-key :end_time_dt) Str, + (optional-key :start_time) Int, + :finding_info {Any Any}, + (optional-key :unmapped) {Any Any}, + (optional-key :activity_name) Str, + (optional-key :timezone_offset) Int, + (optional-key :time_dt) Str, + (optional-key :severity) Str, + (optional-key :category_name) Str, + (optional-key :class_name) Str, + (optional-key :actor) {Any Any}, + (optional-key :raw_data) Str, + (optional-key :confidence_id) Int, + (optional-key :status_id) Int, + (optional-key :type_name) Str, + :activity_id Int, + (optional-key :confidence_score) Int, + (optional-key :resource) {Any Any}, + :metadata {Any Any}, + :compliance {Any Any}, + (optional-key :status_detail) Str, + (optional-key :device) {Any Any}, + (optional-key :enrichments) [{Any Any}]} + (s/explain (schema/->schema (ocsf/->flanders (get-in @ocsf-1-3-0-export ["classes" "compliance_finding"]))))))) + +(defn test-ocsf-version [{:keys [version nobjects nclasses nsamples]}] + (let [;; via https://github.com/frenchy64/ocsf-schema-export + export (-> (json/decode (slurp (io/resource (format "threatgrid/ocsf-%s-export.json" version)))) + ocsf/parse-exported-schemas) + ;; generated by (gen-ocsf-samples) + sample (json/decode (slurp (io/file (format "tmp/flanders/ocsf-%s-sample.json" version))))] + (when (is (= version (get export "version"))) + (doseq [[k nexpected] {"objects" nobjects "classes" nclasses}] + (let [objects (get export k) + examples (get sample k)] + (when (is (= nexpected (count objects))) + (doseq [[name fl] objects] + (when (Thread/interrupted) (throw (InterruptedException.))) + (testing name + (let [m (malli/->malli fl) + s (schema/->schema fl) + good-examples (map walk/keywordize-keys (get examples name))] + (when (is (= nsamples (count good-examples))) + (doseq [good-example good-examples + :let [good-example (if (and (= version "1.1.0") + (= k "classes") + (= name "incident_finding")) + ;; fix https://github.com/ocsf/ocsf-server/issues/123 + (reduce (fn [good-example field] + (if (string? (get good-example field)) + (update good-example field count) + good-example)) + good-example [:priority :distributor]) + good-example) + bad-example (assoc good-example ::junk "foo")]] + (is (nil? (m/explain m good-example)) (pr-str good-example)) + (is (nil? (s/check s good-example)) (pr-str good-example)) + (is (m/explain m bad-example) (pr-str bad-example)) + (is (s/check s bad-example) (pr-str bad-example)))))))))) + (testing "base_event" + (let [base-event (get export "base_event") + examples (get sample "base_event") + _m (is (malli/->malli base-event)) + _s (is (schema/->schema base-event)) + good-examples (map walk/keywordize-keys examples)] + (is (= nsamples (count good-examples))) + #_ ;;TODO examples for base event seem to be an open map? + (doseq [good-example good-examples + :let [bad-example (assoc good-example ::junk "foo")]] + (is (nil? (m/explain m good-example))) + (is (nil? (s/check s good-example))) + (is (m/explain m bad-example)) + (is (s/check s bad-example)))))))) + +;; requires docker running +(deftest ^:integration test-all-ocsf-versions + (gen-ocsf-samples) + (doseq [config all-ocsf-exports] + (testing (:version config) + (test-ocsf-version config)))) + +(deftest cisco-ocsf-schema-test + (is (= 33 (-> "flanders_test/cisco_network_activity.json" io/resource slurp json/decode ocsf/->flanders schema/->schema count)))) diff --git a/test/flanders/schema_test.clj b/test/flanders/schema_test.clj index 6c58c7bb..ad2b44cf 100644 --- a/test/flanders/schema_test.clj +++ b/test/flanders/schema_test.clj @@ -5,8 +5,7 @@ [flanders.examples :refer [Example OptionalKeywordMapEntryExample]] [flanders.schema :as fs] [ring.swagger.json-schema :as js] - [schema.core :as s] - [schema-tools.core :as st])) + [schema.core :as s])) (deftest test-valid-schema (is @@ -80,10 +79,12 @@ "High" "None" "Unknown"}) +#_:clj-kondo/ignore (f/def-enum-type HighMedLow high-med-low :reference (str "[HighMedLowVocab](http://stixproject.github.io/" "data-model/1.2/stixVocabs/HighMediumLowVocab-1.0/)")) +#_:clj-kondo/ignore (f/def-map-type RelatedIdentity (concat (f/required-entries @@ -112,7 +113,7 @@ (constantly true)] :choices [(f/int :description "inner") (f/bool :equals true :description "Foo")])))) - (is (= {:example {} :description "Description"} (->swagger (deref (f/def-entity-type Bar {:description "Description"}))))) + (is (= {:example {} :description "Description"} (->swagger (deref #_:clj-kondo/ignore (f/def-entity-type Bar {:description "Description"}))))) (is (= {:example {:start_time #inst "2016-01-01T01:01:01.000-00:00" :related_identities [{:identity "https://example.com" :confidence "High"