///|
/// Input syntax understood by the configuration import layer.
pub(all) enum ImportFormat {
PlainRules
NginxAccess
CsvRules
} derive(Eq, Debug)
///|
pub fn ImportFormat::label(self : ImportFormat) -> String {
match self {
PlainRules => "plain"
NginxAccess => "nginx_access"
CsvRules => "csv"
}
}
///|
pub(all) enum ImportIssueLevel {
ImportWarning
ImportError
} derive(Eq, Debug)
///|
pub fn ImportIssueLevel::label(self : ImportIssueLevel) -> String {
match self {
ImportWarning => "warning"
ImportError => "error"
}
}
///|
pub struct ImportIssue {
line : Int
level : ImportIssueLevel
code : String
message : String
input : String
} derive(Eq, Debug)
///|
pub fn ImportIssue::new(
line : Int,
level : ImportIssueLevel,
code : String,
message : String,
input : String,
) -> ImportIssue {
{ line, level, code, message, input }
}
///|
pub fn ImportIssue::line(self : ImportIssue) -> Int {
self.line
}
///|
pub fn ImportIssue::level(self : ImportIssue) -> ImportIssueLevel {
self.level
}
///|
pub fn ImportIssue::code(self : ImportIssue) -> String {
self.code
}
///|
pub fn ImportIssue::message(self : ImportIssue) -> String {
self.message
}
///|
pub fn ImportIssue::input(self : ImportIssue) -> String {
self.input
}
///|
pub fn ImportIssue::summary(self : ImportIssue) -> String {
"[" +
self.level.label() +
"] line " +
self.line.to_string() +
" " +
self.code +
": " +
self.message
}
///|
pub struct ImportReport {
format : ImportFormat
source_count : Int
rules : Array[Rule]
issues : Array[ImportIssue]
} derive(Debug)
///|
pub fn ImportReport::format(self : ImportReport) -> ImportFormat {
self.format
}
///|
pub fn ImportReport::source_count(self : ImportReport) -> Int {
self.source_count
}
///|
pub fn ImportReport::rules(self : ImportReport) -> Array[Rule] {
self.rules
}
///|
pub fn ImportReport::issues(self : ImportReport) -> Array[ImportIssue] {
self.issues
}
///|
pub fn ImportReport::accepted_count(self : ImportReport) -> Int {
self.rules.length()
}
///|
pub fn ImportReport::error_count(self : ImportReport) -> Int {
let mut count = 0
for issue in self.issues {
if issue.level() == ImportError {
count = count + 1
}
}
count
}
///|
pub fn ImportReport::warning_count(self : ImportReport) -> Int {
let mut count = 0
for issue in self.issues {
if issue.level() == ImportWarning {
count = count + 1
}
}
count
}
///|
pub fn ImportReport::is_clean(self : ImportReport) -> Bool {
self.issues.length() == 0
}
///|
pub fn ImportReport::can_audit(self : ImportReport) -> Bool {
self.rules.length() > 0
}
///|
pub fn ImportReport::to_ruleset(self : ImportReport) -> RuleSet {
RuleSet::new(self.rules)
}
///|
pub fn ImportReport::summary(self : ImportReport) -> String {
"format=" +
self.format.label() +
", source_lines=" +
self.source_count.to_string() +
", accepted=" +
self.accepted_count().to_string() +
", warnings=" +
self.warning_count().to_string() +
", errors=" +
self.error_count().to_string()
}
///|
pub fn ImportReport::text_report(self : ImportReport) -> String {
let mut output = "MoonCIDR import report\n"
output = output + self.summary() + "\n"
if self.rules.length() > 0 {
output = output + "\nImported rules:\n"
for rule in self.rules {
output = output + "- " + rule.summary() + "\n"
}
}
if self.issues.length() > 0 {
output = output + "\nImport issues:\n"
for issue in self.issues {
output = output + "- " + issue.summary() + "\n"
}
}
output
}
///|
/// Imports the native compact syntax: `allow 10.0.0.0/8 note`.
pub fn import_plain_rules(lines : Array[String]) -> ImportReport {
let rules : Array[Rule] = []
let issues : Array[ImportIssue] = []
for index = 0; index < lines.length(); index = index + 1 {
let text = trim_ascii(lines[index])
if text == "" || starts_with(text, "#") {
continue
}
let id = "R" + (rules.length() + 1).to_string()
match Rule::parse(id, text) {
Ok(rule) => rules.push(rule)
Err(error) =>
issues.push(
ImportIssue::new(
index + 1,
ImportError,
"invalid_rule",
error,
lines[index],
),
)
}
}
{ format: PlainRules, source_count: lines.length(), rules, issues }
}
///|
/// Imports Nginx access directives such as `allow 10.0.0.0/8;`.
///
/// `deny all;` and `allow all;` are converted to `0.0.0.0/0`. Other Nginx
/// directives are rejected so accidental input from an unrelated section is
/// visible to the caller.
pub fn import_nginx_access(lines : Array[String]) -> ImportReport {
let rules : Array[Rule] = []
let issues : Array[ImportIssue] = []
for index = 0; index < lines.length(); index = index + 1 {
let original = lines[index]
let without_comment = strip_comment(original)
let text = trim_ascii(without_comment)
if text == "" {
continue
}
if !ends_with(text, ";") {
issues.push(
ImportIssue::new(
index + 1,
ImportError,
"missing_semicolon",
"Nginx access directive must end with a semicolon",
original,
),
)
continue
}
let directive = trim_ascii(remove_last_char(text))
let words = split_ascii_words(directive)
if words.length() != 2 {
issues.push(
ImportIssue::new(
index + 1,
ImportError,
"invalid_directive",
"expected exactly an allow or deny directive and one address",
original,
),
)
continue
}
let action_text = lower_ascii(words[0])
if action_text != "allow" && action_text != "deny" {
issues.push(
ImportIssue::new(
index + 1,
ImportError,
"unsupported_directive",
"only Nginx allow and deny directives are supported",
original,
),
)
continue
}
let block_text = if lower_ascii(words[1]) == "all" {
"0.0.0.0/0"
} else {
words[1]
}
let native_line = action_text + " " + block_text + " imported from nginx"
match Rule::parse("R" + (rules.length() + 1).to_string(), native_line) {
Ok(rule) => rules.push(rule)
Err(error) =>
issues.push(
ImportIssue::new(
index + 1,
ImportError,
"invalid_address",
error,
original,
),
)
}
}
{ format: NginxAccess, source_count: lines.length(), rules, issues }
}
///|
/// Imports rows in `action,cidr,note` form.
///
/// A header row is optional. Quoted fields, embedded commas, and doubled quote
/// escapes are supported. Extra columns are folded into the note and reported
/// as a warning so information is not silently discarded.
pub fn import_csv_rules(lines : Array[String]) -> ImportReport {
let rules : Array[Rule] = []
let issues : Array[ImportIssue] = []
let mut header_seen = false
for index = 0; index < lines.length(); index = index + 1 {
let original = lines[index]
if trim_ascii(original) == "" {
continue
}
match parse_csv_row(original) {
Err(error) =>
issues.push(
ImportIssue::new(
index + 1,
ImportError,
"invalid_csv",
error,
original,
),
)
Ok(fields) => {
if !header_seen && is_csv_header(fields) {
header_seen = true
continue
}
guard fields.length() >= 2 else {
issues.push(
ImportIssue::new(
index + 1,
ImportError,
"missing_column",
"CSV row must contain at least action and CIDR columns",
original,
),
)
continue
}
let note = csv_note(fields)
if fields.length() > 3 {
issues.push(
ImportIssue::new(
index + 1,
ImportWarning,
"extra_columns",
"extra CSV columns were appended to the note",
original,
),
)
}
let note_suffix = if note == "" { "" } else { " " + note }
let native_line = trim_ascii(fields[0]) +
" " +
trim_ascii(fields[1]) +
note_suffix
match Rule::parse("R" + (rules.length() + 1).to_string(), native_line) {
Ok(rule) => rules.push(rule)
Err(error) =>
issues.push(
ImportIssue::new(
index + 1,
ImportError,
"invalid_rule",
error,
original,
),
)
}
}
}
}
{ format: CsvRules, source_count: lines.length(), rules, issues }
}
///|
fn parse_csv_row(input : String) -> Result[Array[String], String] {
let fields : Array[String] = []
let current : Array[Char] = []
let chars = input.to_array()
let mut quoted = false
let mut index = 0
while index < chars.length() {
let char = chars[index]
if char == '"' {
if quoted && index + 1 < chars.length() && chars[index + 1] == '"' {
current.push('"')
index = index + 2
continue
}
quoted = !quoted
} else if char == ',' && !quoted {
fields.push(trim_ascii(String::from_array(current)))
current.clear()
} else {
current.push(char)
}
index = index + 1
}
guard !quoted else { return Err("CSV row contains an unclosed quote") }
fields.push(trim_ascii(String::from_array(current)))
Ok(fields)
}
///|
fn is_csv_header(fields : Array[String]) -> Bool {
fields.length() >= 2 &&
lower_ascii(trim_ascii(fields[0])) == "action" &&
(
lower_ascii(trim_ascii(fields[1])) == "cidr" ||
lower_ascii(trim_ascii(fields[1])) == "network"
)
}
///|
fn csv_note(fields : Array[String]) -> String {
if fields.length() < 3 {
return ""
}
let mut note = trim_ascii(fields[2])
for index = 3; index < fields.length(); index = index + 1 {
if note != "" {
note = note + ", "
}
note = note + trim_ascii(fields[index])
}
note
}
///|
fn ends_with(input : String, suffix : String) -> Bool {
let chars = input.to_array()
let suffix_chars = suffix.to_array()
if suffix_chars.length() > chars.length() {
return false
}
let offset = chars.length() - suffix_chars.length()
for index = 0; index < suffix_chars.length(); index = index + 1 {
if chars[offset + index] != suffix_chars[index] {
return false
}
}
true
}
///|
fn remove_last_char(input : String) -> String {
let chars = input.to_array()
if chars.length() == 0 {
""
} else {
String::from_array(chars[0:chars.length() - 1])
}
}