commit 22a4e9fbf8ef79b68a48f0247fb17968f289aabb Author: avitex Date: Tue Mar 2 15:17:16 2021 +1100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf0761e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +Cargo.lock + +# STORM +/dist + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..042e433 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "cyberstorm" +version = "0.1.0" +authors = ["avitex "] +edition = "2018" +publish = false +build = "build.rs" + +[dependencies] +zc = "0.4" +serde = { version = "1.0", features = ["derive"] } +toml = "0.5" +async-trait = "0.1" +strum = { version = "0.20", features = ["derive"] } +roxmltree = "0.14" +tokio = { version = "1", features = ["rt-multi-thread", "fs", "macros"] } +validator = { version = "0.12", features = ["derive"] } +thiserror = "1.0" +tera = "1.6" +anyhow = "1.0" +regex = "1" +lazy_static = "1" +structopt = "0.3" +phf = "0.8.0" +includedir = "0.6" + +[build-dependencies] +includedir_codegen = "0.6" +glob = "0.3" diff --git a/README.md b/README.md new file mode 100644 index 0000000..271e573 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# STORM + +An angry cloud. + +A registry of how to protect a system. + +## Tags + +Tags are limited to `a-z`, `0-9`, or `-` characters and can be hierarchically separated via `.`. + +For example when referring to a MITRE ATT&CK technique we could use `attack.t0001`. + +## Domains + +| Domain | Model | Description | +| ---------- | ------------- | ----------------------------------------------------------------------------- | +| [Source] | | Sourcing of intelligence for improving observation, reaction and mitigation | +| | Intelligence | Feed or result of a query used to satisfy intelligence requirements | +| | Requirement | Collection of intelligence used by actions, detections and mitigations | +| | Provider | An internal or external supplier of intelligence | +| [Threat] | | Modelling and simulations of threat actor tactics, techniques and software | +| | Tactic | High-level categorization of threat actor behaviour | +| | Technique | Specific description of threat actor behaviour | +| | Software | Code/utilities/tools used in conducting threat actor behaviour | +| | Simulation | Generating threat behaviour for testing observation, reaction and mitigations | +| [Observe] | | Events, definitions and configuration for observing threat actor behaviour | +| | Event | Observable items use to detect and respond to threat actor behaviour | +| | Detection | Rule used to detect threat actor behaviour mapped to threat actor behaviour | +| | Provider | Software or systems that are capable of producing events | +| | Configuration | Configuration applied to a provider enabling it to produce events | +| [React] | | Structured responses to observed threat behaviour | +| | Stage | Phase of a response to observed threat behaviour | +| | Action | An atomic human action assigned to an response stage | +| | Playbook | Composition of response actions for a given context | +| [Mitigate] | | Proactive and reactive strategies to address address threats | +| | Strategy | Composition of platforms and configurations to address a threat | +| | Platform | Platform that is capable of mitigating threats with or without configuration | +| | Configuration | Configuration applied to a platform enabling it to mitigate threats | + +### Source + +How do you what is happening outside of your system or if something you saw +is elsewhere considered a threat? + +A source is an entity that provides an external perspective and knowledge of +threats, techniques, tools, mitigations that can be pulled or queried. + +#### Provider + +#### Requirement + +#### Intelligence + +### Threat + +### Observe + +### React + +### Mitigate + +source/ + provider `SPR#####` + requirement `SRT#####` + intelligence `SIE#####` +threat/ + tactic `TTC#####` + software `TSE#####` + technique `TTE#####` + deficiency + simulation `TSN#####` +observe/ + event `OET#####` + detection `ODN#####` + provider `OPR#####` + configuration `OCG#####` +react/ + stage `RSE#####` + action `RAN#####` + playbook `RPK#####` +mitigate/ + strategy `MSY#####` + platform `MPM#####` + configuration `MCG#####` diff --git a/book.toml b/book.toml new file mode 100644 index 0000000..6babbad --- /dev/null +++ b/book.toml @@ -0,0 +1,14 @@ +[book] +authors = ["avitex"] +language = "en" +multilingual = false +src = "book" +title = "STORM" + +[build] +create-missing = false +build-dir = "dist" + +[output.html.fold] +enable = true +level = 0 diff --git a/book/.gitignore b/book/.gitignore new file mode 100644 index 0000000..83dbf4f --- /dev/null +++ b/book/.gitignore @@ -0,0 +1,6 @@ +/SUMMARY.md +/source/**/*.md +/threat/**/*.md +/observe/**/*.md +/react/**/*.md +/mitigate/**/*.md diff --git a/book/macros.tera b/book/macros.tera new file mode 100644 index 0000000..cd9a1a9 --- /dev/null +++ b/book/macros.tera @@ -0,0 +1,49 @@ +{% macro references(refs) %} +{%- if refs | length == 0 %}No references{% endif -%} +{%- for ref in refs -%} +- {{ ref | autolink }} +{% endfor -%} +{% endmacro references %} + +{% macro title(title, id) -%} +{{ title }} ([edit]({{ id | domain_id_link(for="edit") }})) +{%- endmacro details_next %} + +{% macro details(id, name) -%} +| Title | {{ name }} | +|:---------------------------:|:------------------------| +{{ self::details_next(title="ID", value=id)}} +{%- endmacro details %} + +{% macro details_next(title, value) -%} +| **{{ title }}** | {{ value }} | +{%- endmacro details_next %} + +{% macro details_authors(authors) -%} +{{ self::details_next(title="Authors", value=authors | join )}} +{%- endmacro details_next %} + +{% macro details_tags(tags) -%} +{% if tags | length == 0 -%} +{{ self::details_next(title="Tags", value="No tags") }} +{%- else -%} +{{ self::details_next(title="Tags", value=tags | join) }} +{%- endif %} +{%- endmacro details_next %} + +{% macro content(content) %} +{%- set trimmed = content | trim -%} +{%- if trimmed | length == 0 %}No description{% else %}{{ trimmed }}{% endif -%} +{% endmacro details_next %} + +{% macro name_and_id_link(value) -%} +{{ value["doc"]["name"] }} ([{{ value["id"] }}]({{ value["id"] | domain_id_link }})) +{%- endmacro name_and_id_link %} + +{% macro summary_list(instances) -%} +| ID | Name | +|:---------------------------:|:------------------------| +{% for item in instances -%} +| [{{ item.id }}]({{ item.id | domain_id_link }}) | {{ item.name }} | +{% endfor %} +{%- endmacro summary_list %} diff --git a/book/observe/event.instance.tera b/book/observe/event.instance.tera new file mode 100644 index 0000000..d9e6ffd --- /dev/null +++ b/book/observe/event.instance.tera @@ -0,0 +1,22 @@ +{% import "macros.tera" as macros %} + +# {{ macros::title(title=doc.name, id=id) }} + +{{ macros::details(id=id, name=doc.name) }} +{%- if doc.WindowsEvent %} +{{ macros::details_next(title="Type", value="Windows event") }} +{% endif -%} +{{ macros::details_next(title="Description", value=doc.description) }} + +## Description + +{{ macros::content(content=doc.content) }} + +{% if doc.WindowsEvent %} +## Samples +{% for sample in doc.WindowsEvent.sample %} +```xml +{{ sample.xml }} +``` +{% endfor %} +{% endif %} diff --git a/book/observe/event.tera b/book/observe/event.tera new file mode 100644 index 0000000..655336c --- /dev/null +++ b/book/observe/event.tera @@ -0,0 +1,7 @@ +{% import "macros.tera" as macros %} + +# Event + +Observable items use to detect and respond to threat actor behaviour. + +{{ macros::summary_list(instances=instances) }} diff --git a/book/react/action.instance.tera b/book/react/action.instance.tera new file mode 100644 index 0000000..6269b83 --- /dev/null +++ b/book/react/action.instance.tera @@ -0,0 +1,17 @@ +{% import "macros.tera" as macros %} +{% set stage = doc.stage | get_doc %} + +# {{ macros::title(title=doc.name, id=id) }} + +{{ macros::details(id=id, name=doc.name) }} +{{ macros::details_next(title="Stage", value=macros::name_and_id_link(value=stage)) }} +{{ macros::details_next(title="Description", value=doc.description) }} +{{ macros::details_tags(tags=doc.tags) }} + +## Description + +{{ macros::content(content=doc.content) }} + +## References + +{{ macros::references(refs=doc.references) }} diff --git a/book/react/action.tera b/book/react/action.tera new file mode 100644 index 0000000..0dd53fe --- /dev/null +++ b/book/react/action.tera @@ -0,0 +1,5 @@ +{% import "macros.tera" as macros %} + +# Action + +{{ macros::summary_list(instances=instances) }} diff --git a/book/react/stage.instance.tera b/book/react/stage.instance.tera new file mode 100644 index 0000000..9dedbb4 --- /dev/null +++ b/book/react/stage.instance.tera @@ -0,0 +1,10 @@ +{% import "macros.tera" as macros %} + +# {{ macros::title(title=doc.name, id=id) }} + +{{ macros::details(id=id, name=doc.name) }} +{{ macros::details_next(title="Description", value=doc.description) }} + +## Description + +{{ macros::content(content=doc.content) }} diff --git a/book/react/stage.tera b/book/react/stage.tera new file mode 100644 index 0000000..f998054 --- /dev/null +++ b/book/react/stage.tera @@ -0,0 +1,7 @@ +{% import "macros.tera" as macros %} + +# Stage + +Phase of a response to observed threat behaviour. + +{{ macros::summary_list(instances=instances) }} diff --git a/book/source/intelligence.instance.tera b/book/source/intelligence.instance.tera new file mode 100644 index 0000000..739972b --- /dev/null +++ b/book/source/intelligence.instance.tera @@ -0,0 +1,14 @@ +{% import "macros.tera" as macros %} + +# {{ macros::title(title=doc.name, id=id) }} + +{{ macros::details(id=id, name=doc.name) }} +{{ macros::details_next(title="Provider", value=doc.provider) }} + +## Description + +{{ macros::content(content=doc.content) }} + +## References + +{{ macros::references(refs=doc.references) }} diff --git a/book/source/intelligence.tera b/book/source/intelligence.tera new file mode 100644 index 0000000..d30f595 --- /dev/null +++ b/book/source/intelligence.tera @@ -0,0 +1,7 @@ +{% import "macros.tera" as macros %} + +# Intelligence + +Feed or result of a query used to satisfy intelligence requirements. + +{{ macros::summary_list(instances=instances) }} diff --git a/book/source/provider.instance.tera b/book/source/provider.instance.tera new file mode 100644 index 0000000..1df6b32 --- /dev/null +++ b/book/source/provider.instance.tera @@ -0,0 +1,13 @@ +{% import "macros.tera" as macros %} + +# {{ macros::title(title=doc.name, id=id) }} + +{{ macros::details(id=id, name=doc.name) }} + +## Description + +{{ macros::content(content=doc.content) }} + +## References + +{{ macros::references(refs=doc.references) }} diff --git a/book/source/provider.tera b/book/source/provider.tera new file mode 100644 index 0000000..9e16b46 --- /dev/null +++ b/book/source/provider.tera @@ -0,0 +1,7 @@ +{% import "macros.tera" as macros %} + +# Provider + +An internal or external supplier of intelligence. + +{{ macros::summary_list(instances=instances) }} diff --git a/book/source/requirement.instance.tera b/book/source/requirement.instance.tera new file mode 100644 index 0000000..1df6b32 --- /dev/null +++ b/book/source/requirement.instance.tera @@ -0,0 +1,13 @@ +{% import "macros.tera" as macros %} + +# {{ macros::title(title=doc.name, id=id) }} + +{{ macros::details(id=id, name=doc.name) }} + +## Description + +{{ macros::content(content=doc.content) }} + +## References + +{{ macros::references(refs=doc.references) }} diff --git a/book/source/requirement.tera b/book/source/requirement.tera new file mode 100644 index 0000000..2f246e5 --- /dev/null +++ b/book/source/requirement.tera @@ -0,0 +1,7 @@ +{% import "macros.tera" as macros %} + +# Requirement + +Collection of intelligence used by actions, detections and mitigations. + +{{ macros::summary_list(instances=instances) }} diff --git a/book/summary.tera b/book/summary.tera new file mode 100644 index 0000000..021832f --- /dev/null +++ b/book/summary.tera @@ -0,0 +1,16 @@ +# Summary + +{%- set last_domain = "" -%} +{%- set last_model = "" -%} +{%- for item in summary -%} +{%- set id_parts = item.id | domain_id -%} +{%- if last_domain != id_parts.domain -%} +{%- set_global last_domain = id_parts.domain %} +# {{ id_parts.domain | capitalize }} +{%- endif -%} +{%- if last_model != id_parts.model -%} +{%- set_global last_model = id_parts.model %} +- [{{ id_parts.model | capitalize }}](.{{ item.id | domain_id_link(for="model") }}) +{%- endif %} + - [{{ item.name }}](.{{ item.id | domain_id_link }}) +{%- endfor -%} diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..2c08267 --- /dev/null +++ b/build.rs @@ -0,0 +1,13 @@ +use glob::glob; +use includedir_codegen::Compression; + +fn main() { + let mut templates = includedir_codegen::start("BASE_TEMPLATES"); + + for path_result in glob("book/**/*.tera").unwrap() { + let path = path_result.unwrap(); + templates.add_file(path, Compression::Gzip).unwrap(); + } + + templates.build("mdbook-templates.rs").unwrap(); +} diff --git a/registry/config.toml b/registry/config.toml new file mode 100644 index 0000000..a2ee499 --- /dev/null +++ b/registry/config.toml @@ -0,0 +1 @@ +edit-link = "https://localhost/registry/{{domain}}/{{model}}/{{model_id}}.md" diff --git a/registry/mitigate/configuration/00000.md b/registry/mitigate/configuration/00000.md new file mode 100644 index 0000000..3b793e3 --- /dev/null +++ b/registry/mitigate/configuration/00000.md @@ -0,0 +1,3 @@ ++++ + ++++ diff --git a/registry/mitigate/platform/00000.md b/registry/mitigate/platform/00000.md new file mode 100644 index 0000000..3b793e3 --- /dev/null +++ b/registry/mitigate/platform/00000.md @@ -0,0 +1,3 @@ ++++ + ++++ diff --git a/registry/mitigate/strategy/00000.md b/registry/mitigate/strategy/00000.md new file mode 100644 index 0000000..3b793e3 --- /dev/null +++ b/registry/mitigate/strategy/00000.md @@ -0,0 +1,3 @@ ++++ + ++++ diff --git a/registry/observe/configuration/00000.md b/registry/observe/configuration/00000.md new file mode 100644 index 0000000..3b793e3 --- /dev/null +++ b/registry/observe/configuration/00000.md @@ -0,0 +1,3 @@ ++++ + ++++ diff --git a/registry/observe/detection/00000.md b/registry/observe/detection/00000.md new file mode 100644 index 0000000..3b793e3 --- /dev/null +++ b/registry/observe/detection/00000.md @@ -0,0 +1,3 @@ ++++ + ++++ diff --git a/registry/observe/event/00000.md b/registry/observe/event/00000.md new file mode 100644 index 0000000..b8f8941 --- /dev/null +++ b/registry/observe/event/00000.md @@ -0,0 +1,43 @@ ++++ +name = "Some event" +description = "Some description" + +[[WindowsEvent.sample]] +xml = """ + + + + 4688 + 2 + 0 + 13312 + 0 + 0x8020000000000000 + + 2814 + + + Security + WIN-GG82ULGC9GO.contoso.local + + + + S-1-5-18 + WIN-GG82ULGC9GO$ + CONTOSO + 0x3e7 + 0x2bc + C:\\Windows\\System32\\rundll32.exe + %%1938 + 0xe74 + S-1-5-21-1377283216-344919071-3415362939-1104 + dadmin + CONTOSO + 0x4a5af0 + C:\\Windows\\explorer.exe + S-1-16-8192 + + +""" ++++ +Something about the event diff --git a/registry/observe/provider/00000.md b/registry/observe/provider/00000.md new file mode 100644 index 0000000..3b793e3 --- /dev/null +++ b/registry/observe/provider/00000.md @@ -0,0 +1,3 @@ ++++ + ++++ diff --git a/registry/react/action/00001.md b/registry/react/action/00001.md new file mode 100644 index 0000000..86ba021 --- /dev/null +++ b/registry/react/action/00001.md @@ -0,0 +1,6 @@ ++++ +name = 'Set up a centralized long-term log storage' +stage = 'RSE00001' +description = 'Set up a centralized long-term log storage. This is one of the most critical problems companies have nowadays. Even if there is such a system, in most of the cases it stores irrelevant data or has too small retention period' ++++ + diff --git a/registry/react/stage/00001.md b/registry/react/stage/00001.md new file mode 100644 index 0000000..9b80dc9 --- /dev/null +++ b/registry/react/stage/00001.md @@ -0,0 +1,4 @@ ++++ +name = "Preparation" +description = "Get prepared for a security incident" ++++ diff --git a/registry/react/stage/00002.md b/registry/react/stage/00002.md new file mode 100644 index 0000000..508e76c --- /dev/null +++ b/registry/react/stage/00002.md @@ -0,0 +1,4 @@ ++++ +name = "Identification" +description = "Gather information about a threat that has triggered a security incident, its TTPs, and affected assets" ++++ \ No newline at end of file diff --git a/registry/react/stage/00003.md b/registry/react/stage/00003.md new file mode 100644 index 0000000..4dd81f1 --- /dev/null +++ b/registry/react/stage/00003.md @@ -0,0 +1,4 @@ ++++ +name = "Containment" +description = "Prevent a threat from achieving its objectives and/or spreading around an environment" ++++ diff --git a/registry/react/stage/00004.md b/registry/react/stage/00004.md new file mode 100644 index 0000000..1d63173 --- /dev/null +++ b/registry/react/stage/00004.md @@ -0,0 +1,4 @@ ++++ +name = "Eradication" +description = "Remove a threat from an environment" ++++ diff --git a/registry/react/stage/00005.md b/registry/react/stage/00005.md new file mode 100644 index 0000000..ba976e6 --- /dev/null +++ b/registry/react/stage/00005.md @@ -0,0 +1,4 @@ ++++ +name = "Recovery" +description = "Recover from the incident and return all the assets back to normal operation" ++++ \ No newline at end of file diff --git a/registry/react/stage/00006.md b/registry/react/stage/00006.md new file mode 100644 index 0000000..08b5a93 --- /dev/null +++ b/registry/react/stage/00006.md @@ -0,0 +1,4 @@ ++++ +name = "Lessons Learned" +description = "Discover how to improve the Incident Response process and implement the improvements" ++++ diff --git a/registry/source/intelligence/00001.md b/registry/source/intelligence/00001.md new file mode 100644 index 0000000..f7e0874 --- /dev/null +++ b/registry/source/intelligence/00001.md @@ -0,0 +1,7 @@ ++++ +name = "DomainTools Whois Lookup" +provider = "SPR0001" +references = [ + "https://whois.domaintools.com/" +] ++++ diff --git a/registry/source/provider/00001.md b/registry/source/provider/00001.md new file mode 100644 index 0000000..eae9f87 --- /dev/null +++ b/registry/source/provider/00001.md @@ -0,0 +1,3 @@ ++++ +name = "DomainTools" ++++ diff --git a/registry/source/provider/00002.md b/registry/source/provider/00002.md new file mode 100644 index 0000000..a7151f6 --- /dev/null +++ b/registry/source/provider/00002.md @@ -0,0 +1,3 @@ ++++ +name = "VirusTotal" ++++ diff --git a/registry/source/requirement/00001.md b/registry/source/requirement/00001.md new file mode 100644 index 0000000..44c00e3 --- /dev/null +++ b/registry/source/requirement/00001.md @@ -0,0 +1,6 @@ ++++ +name = "OSINT assessment of IPv4/IPv6 address" +intelligence = [ + "SIE0001", +] ++++ diff --git a/registry/source/requirement/00002.md b/registry/source/requirement/00002.md new file mode 100644 index 0000000..58b9d49 --- /dev/null +++ b/registry/source/requirement/00002.md @@ -0,0 +1,6 @@ ++++ +name = "OSINT assessment of domain" +intelligence = [ + "SIE0001", +] ++++ diff --git a/registry/source/requirement/00003.md b/registry/source/requirement/00003.md new file mode 100644 index 0000000..fa458b4 --- /dev/null +++ b/registry/source/requirement/00003.md @@ -0,0 +1,3 @@ ++++ +name = "OSINT assessment of MD5 file hash" ++++ diff --git a/registry/source/requirement/00004.md b/registry/source/requirement/00004.md new file mode 100644 index 0000000..08c19b8 --- /dev/null +++ b/registry/source/requirement/00004.md @@ -0,0 +1,3 @@ ++++ +name = "OSINT assessment of SHA256 file hash" ++++ diff --git a/src/document.rs b/src/document.rs new file mode 100644 index 0000000..b66edc0 --- /dev/null +++ b/src/document.rs @@ -0,0 +1,73 @@ +use std::fmt; +use std::str::FromStr; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum MetaError { + #[error("{0}")] + Parse(toml::de::Error), + #[error("meta data not present")] + NotPresent, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Document { + #[serde(flatten)] + pub meta: M, + pub content: String, +} + +impl FromStr for Document +where + M: DeserializeOwned, +{ + type Err = MetaError; + + fn from_str(s: &str) -> Result { + parse_str_parts(s).map(|(meta, content)| Self { + meta, + content: content.to_owned(), + }) + } +} + +impl fmt::Display for Document +where + M: Serialize, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "+++")?; + writeln!(f, "{}", toml::to_string(&self.meta).unwrap())?; + writeln!(f, "\n+++")?; + f.write_str(self.content.as_str()) + } +} + +impl Document +where + M: DeserializeOwned, +{ + pub fn parse_str_meta(s: &str) -> Result { + parse_str_parts(s).map(|(meta, _)| meta) + } +} + +fn parse_str_parts(s: &str) -> Result<(M, &str), MetaError> +where + M: DeserializeOwned, +{ + if let Some(s) = s.strip_prefix("+++") { + let mut parts = s.splitn(2, "\n+++"); + match (parts.next(), parts.next()) { + (Some(meta), Some(content)) => toml::from_str(meta) + .map(|meta| (meta, content)) + .map_err(MetaError::Parse), + _ => Err(MetaError::NotPresent), + } + } else { + Err(MetaError::NotPresent) + } +} diff --git a/src/domains/common.rs b/src/domains/common.rs new file mode 100644 index 0000000..87a2791 --- /dev/null +++ b/src/domains/common.rs @@ -0,0 +1,236 @@ +use std::fmt; +use std::num::{NonZeroU16, ParseIntError}; +use std::str::FromStr; + +use serde::de::{DeserializeOwned, Deserializer, Error}; +use serde::ser::Serializer; +use serde::{Deserialize, Serialize}; +use strum::{EnumString, IntoStaticStr}; + +pub use toml::Value; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct References(Vec); + +pub trait DomainModel: Send + Serialize + DeserializeOwned { + fn kind() -> DomainModelKind; + + fn name(&self) -> &str; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumString, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum DomainKind { + Source, + Threat, + Observe, + React, + Mitigate, +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, EnumString, IntoStaticStr)] +pub enum DomainModelKind { + #[strum(serialize = "SIE")] + SourceIntelligence, + #[strum(serialize = "SPR")] + SourceProvider, + #[strum(serialize = "SRT")] + SourceRequirement, + #[strum(serialize = "TTC")] + ThreatTactic, + #[strum(serialize = "TTE")] + ThreatTechnique, + #[strum(serialize = "TSE")] + ThreatSoftware, + #[strum(serialize = "TSN")] + ThreatSimulation, + #[strum(serialize = "OET")] + ObserveEvent, + #[strum(serialize = "ODN")] + ObserveDetection, + #[strum(serialize = "OPR")] + ObserveProvider, + #[strum(serialize = "OCN")] + ObserveConfiguration, + #[strum(serialize = "RSE")] + ReactStage, + #[strum(serialize = "RAN")] + ReactAction, + #[strum(serialize = "RPK")] + ReactPlaybook, + #[strum(serialize = "MSY")] + MitigateStrategy, + #[strum(serialize = "MPM")] + MitigatePlatform, + #[strum(serialize = "MCN")] + MitigateConfiguration, +} + +impl DomainModelKind { + pub fn domain(&self) -> DomainKind { + match self { + DomainModelKind::SourceIntelligence => DomainKind::Source, + DomainModelKind::SourceProvider => DomainKind::Source, + DomainModelKind::SourceRequirement => DomainKind::Source, + DomainModelKind::ThreatTactic => DomainKind::Threat, + DomainModelKind::ThreatTechnique => DomainKind::Threat, + DomainModelKind::ThreatSoftware => DomainKind::Threat, + DomainModelKind::ThreatSimulation => DomainKind::Threat, + DomainModelKind::ObserveEvent => DomainKind::Observe, + DomainModelKind::ObserveDetection => DomainKind::Observe, + DomainModelKind::ObserveProvider => DomainKind::Observe, + DomainModelKind::ObserveConfiguration => DomainKind::Observe, + DomainModelKind::ReactStage => DomainKind::React, + DomainModelKind::ReactAction => DomainKind::React, + DomainModelKind::ReactPlaybook => DomainKind::React, + DomainModelKind::MitigateStrategy => DomainKind::Mitigate, + DomainModelKind::MitigatePlatform => DomainKind::Mitigate, + DomainModelKind::MitigateConfiguration => DomainKind::Mitigate, + } + } + + pub fn parts(self) -> (&'static str, &'static str) { + match self { + DomainModelKind::SourceIntelligence => ("source", "intelligence"), + DomainModelKind::SourceProvider => ("source", "provider"), + DomainModelKind::SourceRequirement => ("source", "requirement"), + DomainModelKind::ThreatTactic => ("threat", "tactic"), + DomainModelKind::ThreatTechnique => ("threat", "technique"), + DomainModelKind::ThreatSoftware => ("threat", "software"), + DomainModelKind::ThreatSimulation => ("threat", "simulation"), + DomainModelKind::ObserveEvent => ("observe", "event"), + DomainModelKind::ObserveDetection => ("observe", "detection"), + DomainModelKind::ObserveProvider => ("observe", "provider"), + DomainModelKind::ObserveConfiguration => ("observe", "configuration"), + DomainModelKind::ReactStage => ("react", "stage"), + DomainModelKind::ReactAction => ("react", "action"), + DomainModelKind::ReactPlaybook => ("react", "playbook"), + DomainModelKind::MitigateStrategy => ("mitigate", "strategy"), + DomainModelKind::MitigatePlatform => ("mitigate", "platform"), + DomainModelKind::MitigateConfiguration => ("mitigate", "configuration"), + } + } +} + +/////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Copy)] +pub struct ModelId { + pub major: u16, + pub minor: Option, +} + +impl ModelId { + pub fn new(major: u16) -> Self { + Self { major, minor: None } + } + + pub fn is_example(self) -> bool { + self.major == 0 + } +} + +impl fmt::Display for ModelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:0>5}", self.major)?; + if let Some(minor) = self.minor { + write!(f, ".{:0>3}", minor)?; + } + Ok(()) + } +} + +impl FromStr for ModelId { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + let mut parts = s.splitn(2, '.'); + + let major = parts.next().unwrap().parse()?; + let minor = parts.next().map(FromStr::from_str).transpose()?; + + Ok(Self { major, minor }) + } +} + +impl Serialize for ModelId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for ModelId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = <&str>::deserialize(deserializer)?; + s.parse().map_err(D::Error::custom) + } +} + +/////////////////////////////////////////////////////////////////////////////// + +#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Copy)] +pub struct DomainId { + pub kind: DomainModelKind, + pub model_id: ModelId, +} + +impl DomainId { + pub fn new(kind: DomainModelKind, model_id: ModelId) -> Self { + Self { kind, model_id } + } +} + +impl fmt::Debug for DomainId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Id") + .field(&format_args!("{}", self)) + .finish() + } +} + +impl fmt::Display for DomainId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}{}", <&'static str>::from(&self.kind), self.model_id) + } +} + +impl FromStr for DomainId { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + if s.len() > 3 && s.is_char_boundary(3) { + let (kind, model_id) = s.split_at(3); + if let (Ok(kind), Ok(model_id)) = + (DomainModelKind::from_str(kind), ModelId::from_str(model_id)) + { + return Ok(Self { kind, model_id }); + } + } + Err("invalid storm id") + } +} + +impl Serialize for DomainId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for DomainId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = <&str>::deserialize(deserializer)?; + s.parse().map_err(D::Error::custom) + } +} diff --git a/src/domains/mitigate.rs b/src/domains/mitigate.rs new file mode 100644 index 0000000..a1fef66 --- /dev/null +++ b/src/domains/mitigate.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +use crate::domains::common::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Strategy { + pub name: String, +} + +impl DomainModel for Strategy { + fn kind() -> DomainModelKind { + DomainModelKind::MitigateStrategy + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Platform { + pub name: String, +} + +impl DomainModel for Platform { + fn kind() -> DomainModelKind { + DomainModelKind::MitigatePlatform + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Configuration { + pub name: String, +} + +impl DomainModel for Configuration { + fn kind() -> DomainModelKind { + DomainModelKind::MitigateConfiguration + } + + fn name(&self) -> &str { + self.name.as_str() + } +} diff --git a/src/domains/mod.rs b/src/domains/mod.rs new file mode 100644 index 0000000..3098384 --- /dev/null +++ b/src/domains/mod.rs @@ -0,0 +1,54 @@ +use common::DomainModelKind; + +use crate::document::Document; + +pub mod common; +pub mod mitigate; +pub mod observe; +pub mod react; +pub mod source; +pub mod threat; + +pub enum GenericDocument { + SourceIntelligence(Document), + SourceProvider(Document), + SourceRequirement(Document), + ThreatTactic(Document), + ThreatTechnique(Document), + ThreatSoftware(Document), + ThreatSimulation(Document), + ObserveEvent(Document), + ObserveDetection(Document), + ObserveProvider(Document), + ObserveConfiguration(Document), + ReactStage(Document), + ReactAction(Document), + ReactPlaybook(Document), + MitigateStrategy(Document), + MitigatePlatform(Document), + MitigateConfiguration(Document), +} + +impl GenericDocument { + pub fn kind(&self) -> DomainModelKind { + match self { + Self::SourceIntelligence(_) => DomainModelKind::SourceIntelligence, + Self::SourceProvider(_) => DomainModelKind::SourceProvider, + Self::SourceRequirement(_) => DomainModelKind::SourceRequirement, + Self::ThreatTactic(_) => DomainModelKind::ThreatTactic, + Self::ThreatTechnique(_) => DomainModelKind::ThreatTechnique, + Self::ThreatSoftware(_) => DomainModelKind::ThreatSoftware, + Self::ThreatSimulation(_) => DomainModelKind::ThreatSimulation, + Self::ObserveEvent(_) => DomainModelKind::ObserveEvent, + Self::ObserveDetection(_) => DomainModelKind::ObserveDetection, + Self::ObserveProvider(_) => DomainModelKind::ObserveProvider, + Self::ObserveConfiguration(_) => DomainModelKind::ObserveConfiguration, + Self::ReactStage(_) => DomainModelKind::ReactStage, + Self::ReactAction(_) => DomainModelKind::ReactAction, + Self::ReactPlaybook(_) => DomainModelKind::ReactPlaybook, + Self::MitigateStrategy(_) => DomainModelKind::MitigateStrategy, + Self::MitigatePlatform(_) => DomainModelKind::MitigatePlatform, + Self::MitigateConfiguration(_) => DomainModelKind::MitigateConfiguration, + } + } +} diff --git a/src/domains/observe/mod.rs b/src/domains/observe/mod.rs new file mode 100644 index 0000000..3201703 --- /dev/null +++ b/src/domains/observe/mod.rs @@ -0,0 +1,75 @@ +mod windows; + +use serde::{Deserialize, Serialize}; + +use crate::domains::common::*; + +pub use self::windows::WindowsEvent; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Event { + pub name: String, + #[serde(flatten)] + pub body: EventBody, + pub description: String, +} + +impl DomainModel for Event { + fn kind() -> DomainModelKind { + DomainModelKind::ObserveEvent + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum EventBody { + WindowsEvent(WindowsEvent), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Detection { + pub name: String, +} + +impl DomainModel for Detection { + fn kind() -> DomainModelKind { + DomainModelKind::ObserveDetection + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Provider { + pub name: String, +} + +impl DomainModel for Provider { + fn kind() -> DomainModelKind { + DomainModelKind::ObserveProvider + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Configuration { + pub name: String, +} + +impl DomainModel for Configuration { + fn kind() -> DomainModelKind { + DomainModelKind::ObserveConfiguration + } + + fn name(&self) -> &str { + self.name.as_str() + } +} diff --git a/src/domains/observe/windows.rs b/src/domains/observe/windows.rs new file mode 100644 index 0000000..6a793fd --- /dev/null +++ b/src/domains/observe/windows.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; + +use crate::util::xml; + +#[derive(Debug, Serialize, Deserialize)] +pub struct WindowsEvent { + pub sample: Vec, +} + +#[derive(Debug)] +pub struct WindowsEventSummary<'a> { + pub event_id: u32, + pub channel: &'a str, + pub provider_name: &'a str, + pub provider_guid: Option<&'a str>, +} + +impl WindowsEvent { + pub fn summary(&self) -> Result, &'static str> { + let sample = self.sample.get(0).ok_or("no sample")?; + let event_id: u32 = sample + .event_id() + .ok_or("no event id")? + .parse() + .map_err(|_| "event id not u32")?; + let (provider_name, provider_guid) = sample.provider(); + Ok(WindowsEventSummary { + event_id, + channel: sample.channel().ok_or("no channel")?, + provider_name: provider_name.ok_or("no provider name")?, + provider_guid, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Sample { + pub xml: xml::DocumentBuf, + pub description: Option, +} + +impl Sample { + pub fn provider_name(&self) -> Option<&str> { + self.provider().0 + } + + pub fn provider(&self) -> (Option<&str>, Option<&str>) { + self.get_event_element("System", "Provider") + .map(|p| (p.attribute("Name"), p.attribute("Guid"))) + .unwrap_or_else(|| (None, None)) + } + + pub fn channel(&self) -> Option<&str> { + self.get_event_element("System", "Channel") + .and_then(|n| n.text()) + } + + pub fn event_id(&self) -> Option<&str> { + self.get_event_element("System", "EventID") + .and_then(|n| n.text()) + } + + fn get_event_element(&self, parent: &str, element: &str) -> Option { + self.xml + .document() + .root_element() + .children() + .find(|c| c.tag_name().name() == parent) + .and_then(|c| c.children().find(|c| c.tag_name().name() == element)) + } +} diff --git a/src/domains/react.rs b/src/domains/react.rs new file mode 100644 index 0000000..a4bd062 --- /dev/null +++ b/src/domains/react.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::domains::common::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Stage { + pub name: String, + pub description: String, + #[serde(default)] + pub additional: Vec<(String, Value)>, +} + +impl DomainModel for Stage { + fn kind() -> DomainModelKind { + DomainModelKind::ReactStage + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +/////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug, Serialize, Deserialize)] +pub struct Action { + pub name: String, + pub description: String, + pub stage: DomainId, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub references: References, + #[serde(default)] + pub additional: Vec<(String, Value)>, +} + +impl DomainModel for Action { + fn kind() -> DomainModelKind { + DomainModelKind::ReactAction + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +/////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug, Serialize, Deserialize)] +pub struct Playbook { + pub name: String, + pub tags: Vec, + pub description: String, + pub references: References, + pub stages: HashMap>, + #[serde(default)] + pub additional: Vec<(String, Value)>, +} + +impl DomainModel for Playbook { + fn kind() -> DomainModelKind { + DomainModelKind::ReactPlaybook + } + + fn name(&self) -> &str { + self.name.as_str() + } +} diff --git a/src/domains/source.rs b/src/domains/source.rs new file mode 100644 index 0000000..00307cb --- /dev/null +++ b/src/domains/source.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; + +use crate::domains::common::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Requirement { + pub name: String, + #[serde(default)] + pub intelligence: Vec, + #[serde(default)] + pub references: References, + #[serde(default)] + pub additional: Vec<(String, Value)>, +} + +impl DomainModel for Requirement { + fn kind() -> DomainModelKind { + DomainModelKind::SourceRequirement + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +/////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug, Serialize, Deserialize)] +pub struct Intelligence { + pub name: String, + pub provider: DomainId, + #[serde(default)] + pub references: References, + #[serde(default)] + pub additional: Vec<(String, Value)>, +} + +impl DomainModel for Intelligence { + fn kind() -> DomainModelKind { + DomainModelKind::SourceIntelligence + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +/////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug, Serialize, Deserialize)] +pub struct Provider { + pub name: String, + #[serde(default)] + pub references: References, + #[serde(default)] + pub additional: Vec<(String, Value)>, +} + +impl DomainModel for Provider { + fn kind() -> DomainModelKind { + DomainModelKind::SourceProvider + } + + fn name(&self) -> &str { + self.name.as_str() + } +} diff --git a/src/domains/threat.rs b/src/domains/threat.rs new file mode 100644 index 0000000..fe98890 --- /dev/null +++ b/src/domains/threat.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; + +use crate::domains::common::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Tactic { + pub name: String, +} + +impl DomainModel for Tactic { + fn kind() -> DomainModelKind { + DomainModelKind::ThreatTactic + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Technique { + pub name: String, +} + +impl DomainModel for Technique { + fn kind() -> DomainModelKind { + DomainModelKind::ThreatTechnique + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Software { + pub name: String, +} + +impl DomainModel for Software { + fn kind() -> DomainModelKind { + DomainModelKind::ThreatSoftware + } + + fn name(&self) -> &str { + self.name.as_str() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Simulation { + pub name: String, +} + +impl DomainModel for Simulation { + fn kind() -> DomainModelKind { + DomainModelKind::ThreatSimulation + } + + fn name(&self) -> &str { + self.name.as_str() + } +} diff --git a/src/generator/mdbook.rs b/src/generator/mdbook.rs new file mode 100644 index 0000000..f47473e --- /dev/null +++ b/src/generator/mdbook.rs @@ -0,0 +1,357 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::{io, mem}; + +use async_trait::async_trait; +use lazy_static::lazy_static; +use regex::Regex; +use serde::Serialize; +use tera::{Context, Map, Tera, Value}; +use thiserror::Error; +use tokio::fs; + +include!(concat!(env!("OUT_DIR"), "/mdbook-templates.rs")); + +use crate::document::Document; +use crate::domains::common::{DomainId, DomainModel, ModelId}; +use crate::domains::GenericDocument; +use crate::registry::{Registry, RegistryConfig}; + +use crate::generator::{Engine, GeneratorError}; + +#[derive(Debug, Error)] +pub enum MDBookEngineError { + #[error("{0}")] + Io(#[from] io::Error), + #[error("{0}")] + Tera(#[from] tera::Error), +} + +impl From for GeneratorError { + fn from(err: MDBookEngineError) -> Self { + GeneratorError::Engine(err.into()) + } +} + +#[derive(Debug, Serialize)] +struct SummaryItem { + id: DomainId, + name: String, +} + +pub struct MDBookEngine { + inner: Inner, +} + +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +enum Inner { + Start { + src: PathBuf, + }, + LoadAndRender { + src: PathBuf, + templates: Tera, + summary: Vec, + documents: HashMap, + }, + Finish, +} + +impl MDBookEngine { + pub fn new(src: &Path) -> Result { + Ok(Self { + inner: Inner::Start { + src: src.canonicalize()?, + }, + }) + } +} + +// (?Send) +#[async_trait] +impl Engine for MDBookEngine { + async fn start(&mut self, _registry: &R) -> Result<(), GeneratorError> + where + R: Registry, + { + if let Inner::Start { src } = mem::replace(&mut self.inner, Inner::Finish) { + let templates = load_templates(&src).await?; + let documents = HashMap::new(); + let summary = Vec::new(); + self.inner = Inner::LoadAndRender { + src, + templates, + documents, + summary, + }; + } + Ok(()) + } + + async fn push_document( + &mut self, + model_id: ModelId, + doc: GenericDocument, + _registry: &R, + ) -> Result<(), GeneratorError> + where + R: Registry, + { + if let Inner::LoadAndRender { + ref mut summary, + ref mut documents, + .. + } = self.inner + { + macro_rules! push_documents { + ($($kind:ident),+) => { + match doc { + $( + GenericDocument::$kind(doc) => { + save_domain_model(summary, documents, model_id, &doc); + } + )+ + } + }; + } + push_documents!( + SourceIntelligence, + SourceRequirement, + SourceProvider, + ThreatTactic, + ThreatTechnique, + ThreatSoftware, + ThreatSimulation, + ObserveEvent, + ObserveDetection, + ObserveProvider, + ObserveConfiguration, + ReactStage, + ReactAction, + ReactPlaybook, + MitigateStrategy, + MitigatePlatform, + MitigateConfiguration + ); + } + + Ok(()) + } + + async fn finish(&mut self, registry: &R) -> Result<(), GeneratorError> + where + R: Registry, + { + if let Inner::LoadAndRender { + src, + mut templates, + documents, + summary, + } = mem::replace(&mut self.inner, Inner::Finish) + { + let summary = Arc::new(summary); + let documents = Arc::new(documents); + let registry_config = registry.get_config().await?; + register_filters(&mut templates, documents.clone(), registry_config); + render_indexes(&templates, &src, &summary).await?; + render_summary(&templates, &src, &summary).await?; + render_documents(&templates, &src, documents).await?; + } + Ok(()) + } +} + +async fn load_templates(src: &Path) -> Result { + let mut files = Vec::new(); + + for filename in BASE_TEMPLATES.file_names() { + let name = filename.strip_prefix("book/").unwrap(); + let external = src.join(name); + + let content = if external.is_file() { + fs::read_to_string(external).await? + } else { + let mut content = String::new(); + BASE_TEMPLATES + .read(filename)? + .read_to_string(&mut content)?; + content + }; + + files.push((name, content)); + } + + let mut templates = Tera::default(); + templates.add_raw_templates(files)?; + Ok(templates) +} + +fn save_domain_model( + summary: &mut Vec, + documents: &mut HashMap, + model_id: ModelId, + doc: &Document, +) where + M: DomainModel, +{ + let id = DomainId::new(M::kind(), model_id); + let mut value = Map::new(); + value.insert("id".to_owned(), tera::to_value(&id).unwrap()); + value.insert("doc".to_owned(), tera::to_value(&doc).unwrap()); + documents.insert(id, value.into()); + summary.push(SummaryItem { + id, + name: doc.meta.name().to_owned(), + }); +} + +async fn render_documents( + templates: &Tera, + src: &Path, + documents: Arc>, +) -> Result<(), MDBookEngineError> { + for (id, value) in documents.iter() { + let (domain, model) = id.kind.parts(); + let model_path = format!("{}/{}", domain, model); + let template_path = format!("{}.instance.tera", model_path); + let output_path = src + .join(model_path) + .join(id.model_id.to_string()) + .with_extension("md"); + let context = Context::from_value(value.clone())?; + let contents = templates.render(&template_path, &context)?; + fs::write(output_path, contents).await?; + } + Ok(()) +} + +async fn render_summary( + templates: &Tera, + src: &Path, + summary: &[SummaryItem], +) -> Result<(), MDBookEngineError> { + let mut context = Context::new(); + context.insert("summary", summary); + + let contents = templates.render("summary.tera", &context)?; + let summary_path = src.join("SUMMARY.md"); + fs::write(summary_path, contents).await?; + Ok(()) +} + +async fn render_indexes( + templates: &Tera, + src: &Path, + summary: &[SummaryItem], +) -> Result<(), MDBookEngineError> { + let mut last_i = 0; + let mut last_kind = None; + for (i, item) in summary.iter().enumerate() { + if Some(item.id.kind) != last_kind { + let (domain, model) = item.id.kind.parts(); + + let domain_dir = src.join(domain); + let model_dir = domain_dir.join(model); + + let mut context = Context::new(); + context.insert("instances", &summary[last_i..i]); + + if !domain_dir.is_dir() { + fs::create_dir(&domain_dir).await?; + } + if !model_dir.is_dir() { + fs::create_dir(&model_dir).await?; + } + + let model_index = model_dir.with_extension("md"); + let template_path = format!("{}/{}.tera", domain, model); + let contents = templates.render(&template_path, &context)?; + fs::write(model_index, contents).await?; + + last_i = i; + last_kind = Some(item.id.kind); + } + } + + Ok(()) +} + +fn register_filters( + tera: &mut Tera, + registry: Arc>, + registry_config: Arc, +) { + use tera::{try_get_value, Error}; + + fn parse_domain_id(value: &Value) -> Result { + let id = try_get_value!("link", "value", String, value); + id.parse() + .map_err(|_| Error::msg(format_args!("failed to parse domain id `{}`", id))) + } + + tera.register_filter( + "domain_id_link", + move |value: &Value, args: &HashMap| { + let id = parse_domain_id(value)?; + let (domain, model) = id.kind.parts(); + let link = match args.get("for").and_then(|v| v.as_str()) { + None => format!("/{}/{}/{}.md", domain, model, id.model_id), + Some("model") => format!("/{}/{}.md", domain, model), + Some("edit") => registry_config.edit_link(id).map_err(Error::msg)?, + Some(_) => { + return Err(Error::msg( + "domain_id_link `for` arg can only be `model|edit`", + )) + } + }; + Ok(Value::String(link)) + }, + ); + + tera.register_filter( + "domain_id", + move |value: &Value, _: &HashMap| { + let id = parse_domain_id(value)?; + let (domain, model) = id.kind.parts(); + let mut parts = Map::with_capacity(4); + parts.insert("domain".to_owned(), domain.into()); + parts.insert("model".to_owned(), model.into()); + parts.insert("model_id".to_owned(), id.model_id.to_string().into()); + Ok(Value::Object(parts)) + }, + ); + + tera.register_filter( + "get_doc", + move |value: &Value, _: &HashMap| { + let id = parse_domain_id(value)?; + registry + .get(&id) + .cloned() + .ok_or_else(|| Error::msg(format_args!("unknown document id {}", id))) + }, + ); + + tera.register_filter("autolink", |value: &Value, _: &HashMap| { + let text = try_get_value!("autolink", "value", String, value); + + if text.is_empty() { + return Ok(Value::String(String::new())); + } + + lazy_static! { + static ref URL: Regex = Regex::new( + r"(?ix) + \b(([\w-]+://?|www[.])[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/))) + ", + ) + .unwrap(); + } + + let replaced = URL.replace_all(text.as_str(), "[$0]($0)").into_owned(); + + Ok(Value::String(replaced)) + }); +} diff --git a/src/generator/mod.rs b/src/generator/mod.rs new file mode 100644 index 0000000..bd9187b --- /dev/null +++ b/src/generator/mod.rs @@ -0,0 +1,101 @@ +pub mod mdbook; + +use async_trait::async_trait; +use thiserror::Error; + +use crate::document::Document; +use crate::domains::common::{DomainId, DomainModel, ModelId}; +use crate::domains::GenericDocument; +use crate::registry::{Registry, RegistryError}; + +#[derive(Debug, Error)] +pub enum GeneratorError { + #[error("{0}")] + Engine(#[from] anyhow::Error), + #[error("{0}")] + Registry(#[from] RegistryError), +} + +pub struct Generator { + engine: E, + registry: R, +} + +impl Generator +where + E: Engine, + R: Registry, +{ + pub fn new(engine: E, registry: R) -> Self { + Self { engine, registry } + } + + pub async fn generate(mut self) -> Result<(), GeneratorError> { + self.engine.start(&self.registry).await?; + macro_rules! push_documents { + ($($kind:ident),+) => { + $( + for (id, doc) in self.get_documents().await? { + self.engine + .push_document(id.model_id, GenericDocument::$kind(doc), &self.registry) + .await?; + } + )* + }; + } + push_documents!( + SourceIntelligence, + SourceRequirement, + SourceProvider, + ThreatTactic, + ThreatTechnique, + ThreatSoftware, + ThreatSimulation, + ObserveEvent, + ObserveDetection, + ObserveProvider, + ObserveConfiguration, + ReactStage, + ReactAction, + ReactPlaybook, + MitigateStrategy, + MitigatePlatform, + MitigateConfiguration + ); + self.engine.finish(&self.registry).await + } + + async fn get_documents(&self) -> Result)>, GeneratorError> + where + M: DomainModel, + { + self.registry + .get_documents() + .await + .map(|mut docs| { + docs.sort_by_key(|(id, _)| *id); + docs + }) + .map_err(GeneratorError::Registry) + } +} + +#[async_trait] +pub trait Engine { + async fn start(&mut self, registry: &R) -> Result<(), GeneratorError> + where + R: Registry; + + async fn push_document( + &mut self, + id: ModelId, + doc: GenericDocument, + registry: &R, + ) -> Result<(), GeneratorError> + where + R: Registry; + + async fn finish(&mut self, registry: &R) -> Result<(), GeneratorError> + where + R: Registry; +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..df11e01 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +mod util; + +pub mod document; +pub mod domains; +pub mod generator; +pub mod registry; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0264071 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,72 @@ +use std::path::PathBuf; +use std::str::FromStr; + +use anyhow::{Context, Error}; +use structopt::StructOpt; + +use cyberstorm::generator::mdbook::MDBookEngine; +use cyberstorm::generator::Generator; +use cyberstorm::registry::{DirectoryRegistry, SupportedRegistry}; + +#[derive(Debug, StructOpt)] +#[structopt(name = "cyberstorm")] +struct Opt { + /// Registry to use. + registry: RegistryOpt, + + #[structopt(subcommand)] + cmd: Cmd, +} + +#[derive(Debug, StructOpt)] +enum Cmd { + BuildMdbook(BuildMdbookOpt), +} + +#[derive(Debug, StructOpt)] +struct BuildMdbookOpt { + /// Path to the output MDBook directory. + #[structopt(parse(from_os_str))] + book: PathBuf, +} + +#[derive(Debug)] +enum RegistryOpt { + Directory(PathBuf), +} + +impl FromStr for RegistryOpt { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + Ok(Self::Directory(PathBuf::from(s))) + } +} + +async fn run(opt: Opt) -> Result<(), Error> { + let registry = match &opt.registry { + RegistryOpt::Directory(path) => SupportedRegistry::Directory(DirectoryRegistry::new(path)), + }; + match &opt.cmd { + Cmd::BuildMdbook(build_opt) => build_mdbook(&opt, build_opt, ®istry).await, + } +} + +async fn build_mdbook( + _opt: &Opt, + build_opt: &BuildMdbookOpt, + registry: &SupportedRegistry, +) -> Result<(), Error> { + let engine = MDBookEngine::new(&build_opt.book).context("failed to load mdbook engine")?; + Generator::new(engine, registry) + .generate() + .await + .context("failed to generate mdbook content") +} + +#[tokio::main] +async fn main() { + if let Err(err) = run(Opt::from_args()).await { + eprintln!("{:#}", err); + } +} diff --git a/src/registry/directory.rs b/src/registry/directory.rs new file mode 100644 index 0000000..19ed37f --- /dev/null +++ b/src/registry/directory.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use async_trait::async_trait; +use tokio::fs; + +use crate::document::Document; +use crate::domains::common::*; +use crate::registry::{Registry, RegistryConfig, RegistryError}; + +#[derive(Debug)] +pub struct DirectoryRegistry { + path: PathBuf, +} + +impl DirectoryRegistry { + pub fn new>(path: P) -> Self { + Self { path: path.into() } + } + + fn domain_model_kind_path(&self, kind: DomainModelKind) -> PathBuf { + let (domain, model) = kind.parts(); + self.path.join(format!("{}/{}", domain, model)) + } + + fn domain_id_path(&self, id: DomainId) -> PathBuf { + let (domain, model) = id.kind.parts(); + self.path + .join(format!("{}/{}/{}.md", domain, model, id.model_id)) + } +} + +#[async_trait] +impl Registry for DirectoryRegistry { + async fn get_config(&self) -> Result, RegistryError> { + let config_str = fs::read_to_string(self.path.join("config.toml")).await?; + toml::from_str(&config_str) + .context("failed to read registry `config.toml`") + .map(Arc::new) + .map_err(Into::into) + } + + async fn get_document(&self, model_id: ModelId) -> Result, RegistryError> + where + M: DomainModel, + { + let id = DomainId::new(M::kind(), model_id); + let path = self.domain_id_path(id); + if !path.is_file() { + return Err(RegistryError::NotFound(id)); + } + let s = fs::read_to_string(path).await?; + s.parse().map_err(|err| RegistryError::Document(id, err)) + } + + async fn get_documents(&self) -> Result)>, RegistryError> + where + M: DomainModel, + { + let ids = self.get_document_ids(M::kind()).await?; + let mut docs = Vec::with_capacity(ids.len()); + for id in ids { + docs.push((id, self.get_document(id.model_id).await?)); + } + Ok(docs) + } + + async fn get_document_ids( + &self, + kind: DomainModelKind, + ) -> Result, RegistryError> { + let mut ids = Vec::new(); + let path = self.domain_model_kind_path(kind); + if !path.is_dir() { + return Ok(vec![]); + } + let mut dirs = fs::read_dir(path).await?; + while let Some(entry) = dirs.next_entry().await? { + let path = entry.path(); + if path.is_file() { + let filestem = path.file_stem().unwrap().to_string_lossy(); + let model_id: ModelId = filestem.as_ref().parse().unwrap(); + if !model_id.is_example() { + ids.push(DomainId::new(kind, model_id)) + } + } + } + Ok(ids) + } + + async fn put_document( + &self, + model_id: ModelId, + document: Document, + ) -> Result<(), RegistryError> + where + M: DomainModel, + { + let path = self.domain_id_path(DomainId::new(M::kind(), model_id)); + let document = document.to_string(); + fs::write(path, document.as_str()).await?; + Ok(()) + } +} diff --git a/src/registry/mod.rs b/src/registry/mod.rs new file mode 100644 index 0000000..a460ebd --- /dev/null +++ b/src/registry/mod.rs @@ -0,0 +1,178 @@ +mod directory; + +use std::fmt::Debug; +use std::sync::Arc; + +use anyhow::{anyhow, Context as _}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tera::{Context, Tera}; +use thiserror::Error; + +use crate::document::{Document, MetaError}; +use crate::domains::common::*; + +pub use self::directory::DirectoryRegistry; + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error("config error: {0}")] + Config(anyhow::Error), + #[error("failed to parse document {0}: {1}")] + Document(DomainId, MetaError), + #[error("domain id {0} not found")] + NotFound(DomainId), + #[error("{0}")] + Other(#[from] anyhow::Error), +} + +impl From for RegistryError { + fn from(err: std::io::Error) -> Self { + Self::Other(err.into()) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct RegistryConfig { + edit_link: Option, +} + +impl RegistryConfig { + pub fn edit_link(&self, domain_id: DomainId) -> Result { + let edit_link = self + .edit_link + .as_deref() + .ok_or_else(|| RegistryError::Config(anyhow!("edit-link option not set")))?; + let (domain, model) = domain_id.kind.parts(); + let mut context = Context::new(); + context.insert("id", &domain_id); + context.insert("domain", domain); + context.insert("model", model); + context.insert("model_id", &domain_id.model_id); + + Tera::one_off(edit_link, &context, false) + .context("error rendering edit link") + .map_err(RegistryError::Config) + } +} + +#[async_trait] +pub trait Registry: Send + Sync { + async fn get_config(&self) -> Result, RegistryError>; + + async fn get_document(&self, model_id: ModelId) -> Result, RegistryError> + where + M: DomainModel; + + async fn get_documents(&self) -> Result)>, RegistryError> + where + M: DomainModel; + + async fn get_document_ids(&self, kind: DomainModelKind) + -> Result, RegistryError>; + + async fn put_document( + &self, + model_id: ModelId, + document: Document, + ) -> Result<(), RegistryError> + where + M: DomainModel; +} + +#[async_trait] +impl Registry for &T +where + T: Registry, +{ + async fn get_config(&self) -> Result, RegistryError> { + (**self).get_config().await + } + + async fn get_document(&self, model_id: ModelId) -> Result, RegistryError> + where + M: DomainModel, + { + (**self).get_document(model_id).await + } + + async fn get_documents(&self) -> Result)>, RegistryError> + where + M: DomainModel, + { + (**self).get_documents().await + } + + async fn get_document_ids( + &self, + kind: DomainModelKind, + ) -> Result, RegistryError> { + (**self).get_document_ids(kind).await + } + + async fn put_document( + &self, + model_id: ModelId, + document: Document, + ) -> Result<(), RegistryError> + where + M: DomainModel, + { + (**self).put_document(model_id, document).await + } +} + +#[derive(Debug)] +pub enum SupportedRegistry { + Directory(DirectoryRegistry), +} + +#[async_trait] +impl Registry for SupportedRegistry { + async fn get_config(&self) -> Result, RegistryError> { + match self { + Self::Directory(r) => r.get_config().await, + } + } + + async fn get_document(&self, model_id: ModelId) -> Result, RegistryError> + where + M: DomainModel, + { + match self { + Self::Directory(r) => r.get_document(model_id).await, + } + } + + async fn get_documents(&self) -> Result)>, RegistryError> + where + M: DomainModel, + { + match self { + Self::Directory(r) => r.get_documents().await, + } + } + + async fn get_document_ids( + &self, + kind: DomainModelKind, + ) -> Result, RegistryError> { + match self { + Self::Directory(r) => r.get_document_ids(kind).await, + } + } + + async fn put_document( + &self, + model_id: ModelId, + document: Document, + ) -> Result<(), RegistryError> + where + M: DomainModel, + { + match self { + Self::Directory(r) => r.put_document(model_id, document).await, + } + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..2910ec6 --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1 @@ +pub mod xml; diff --git a/src/util/xml.rs b/src/util/xml.rs new file mode 100644 index 0000000..a2546d2 --- /dev/null +++ b/src/util/xml.rs @@ -0,0 +1,64 @@ +use core::convert::TryFrom; +use core::fmt; + +use roxmltree::Document; +use zc::{Dependant, Zc}; + +pub use roxmltree::{self, *}; + +pub struct DocumentBuf(Zc>); + +impl DocumentBuf { + pub fn document<'input>(&'input self) -> &Document<'input> { + &self.0.get::>().0 + } +} + +impl fmt::Debug for DocumentBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self.document(), f) + } +} + +impl TryFrom for DocumentBuf { + type Error = roxmltree::Error; + + fn try_from(s: String) -> Result { + match zc::try_from!(s, DependantDocument, str) { + Ok(doc) => Ok(Self(doc)), + Err((err, _)) => Err(err), + } + } +} + +impl serde::ser::Serialize for DocumentBuf { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + serializer.serialize_str(self.0.as_owned()) + } +} + +impl<'de> serde::de::Deserialize<'de> for DocumentBuf { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + use serde::de::Error; + Self::try_from(String::deserialize(deserializer)?).map_err(D::Error::custom) + } +} +struct DependantDocument<'input>(Document<'input>); + +unsafe impl<'o> Dependant<'o> for DependantDocument<'o> { + type Static = DependantDocument<'static>; +} + +impl<'input> TryFrom<&'input str> for DependantDocument<'input> { + type Error = roxmltree::Error; + + fn try_from(s: &'input str) -> Result { + Document::parse(s).map(Self) + } +}