Add DRL ingestion script and IngestReport/IngestedRule models
This commit is contained in:
@@ -0,0 +1,320 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Ingest one or more IriusRisk Drools (.drl) files and produce a structured
|
||||||
|
catalog of every rule, classified by how well it maps to our supported subset.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/ingest.py path/to/rules.drl
|
||||||
|
python scripts/ingest.py path/to/drl/directory/
|
||||||
|
python scripts/ingest.py path/to/rules.drl --json > catalog.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from iriusrisk_drl_toolkit.models import IngestedRule, IngestReport
|
||||||
|
|
||||||
|
PACKAGE_RE = re.compile(r"^\s*package\s+([A-Za-z0-9_.]+)\s*$", re.MULTILINE)
|
||||||
|
IMPORT_RE = re.compile(r"^\s*import\s+([A-Za-z0-9_.*]+)\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
RULE_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
rule\s+"(?P<name>[^"]+)"\s*
|
||||||
|
(?P<attrs>.*?)
|
||||||
|
when\s
|
||||||
|
(?P<when>.*?)
|
||||||
|
then\s
|
||||||
|
(?P<then>.*?)
|
||||||
|
end
|
||||||
|
""",
|
||||||
|
re.DOTALL | re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
SALIENCE_RE = re.compile(r"\bsalience\s+(-?\d+)\b")
|
||||||
|
NO_LOOP_RE = re.compile(r"\bno-loop\s+true\b")
|
||||||
|
|
||||||
|
PATTERN_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
\$\w+\s*:\s*
|
||||||
|
(\w+)\s*\(
|
||||||
|
(.*?)
|
||||||
|
\)
|
||||||
|
""",
|
||||||
|
re.DOTALL | re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
RELATION_TYPE_RE = re.compile(r'relationType\s+==\s*"([^"]+)"')
|
||||||
|
COMPONENT_TYPE_RE = re.compile(r'\btype\s+==\s*"([^"]+)"')
|
||||||
|
|
||||||
|
ACTION_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
(\w+(?:\.\w+)*)\s*
|
||||||
|
\.\s*
|
||||||
|
(\w+)\s*\(
|
||||||
|
(.*?)
|
||||||
|
\)\s*;
|
||||||
|
""",
|
||||||
|
re.DOTALL | re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
ACTIVITY_KINDS = frozenset({
|
||||||
|
"CREATE_THREAT",
|
||||||
|
"CREATE_COUNTERMEASURE",
|
||||||
|
"AUTO_RESOLVE",
|
||||||
|
"CREATE_RISK_PATTERN",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def split_drl(file_text: str) -> tuple[str | None, list[str]]:
|
||||||
|
package = PACKAGE_RE.search(file_text)
|
||||||
|
package_name = package.group(1) if package else None
|
||||||
|
imports = IMPORT_RE.findall(file_text)
|
||||||
|
return package_name, imports
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rules(file_text: str) -> list[dict]:
|
||||||
|
return [m.groupdict() for m in RULE_RE.finditer(file_text)]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_patterns(when_block: str) -> list[dict]:
|
||||||
|
patterns = []
|
||||||
|
for m in PATTERN_RE.finditer(when_block):
|
||||||
|
patterns.append({
|
||||||
|
"type": m.group(1),
|
||||||
|
"body": m.group(2).strip(),
|
||||||
|
})
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
|
||||||
|
def parse_actions(then_block: str) -> list[dict]:
|
||||||
|
actions = []
|
||||||
|
for m in ACTION_RE.finditer(then_block):
|
||||||
|
actions.append({
|
||||||
|
"object": m.group(1),
|
||||||
|
"method": m.group(2),
|
||||||
|
"args": m.group(3).strip(),
|
||||||
|
})
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def classify_rule(
|
||||||
|
name: str,
|
||||||
|
when_block: str,
|
||||||
|
then_block: str,
|
||||||
|
patterns: list[dict],
|
||||||
|
actions: list[dict],
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
pattern_types = [p["type"] for p in patterns]
|
||||||
|
has_relation = "Relation" in pattern_types
|
||||||
|
has_component = "Component" in pattern_types
|
||||||
|
|
||||||
|
if has_relation and has_component:
|
||||||
|
rel_match = RELATION_TYPE_RE.search(when_block)
|
||||||
|
activity_kind: str | None = None
|
||||||
|
for a in actions:
|
||||||
|
if a["method"] == "createActivity":
|
||||||
|
args = a["args"]
|
||||||
|
m = re.search(r'"([^"]+)"', args)
|
||||||
|
if m and m.group(1) in ACTIVITY_KINDS:
|
||||||
|
activity_kind = m.group(1)
|
||||||
|
|
||||||
|
if rel_match and activity_kind:
|
||||||
|
return (
|
||||||
|
"relation_rule",
|
||||||
|
f"relation_type={rel_match.group(1)!r}, activity={activity_kind!r}",
|
||||||
|
)
|
||||||
|
return ("relation_rule", "relation+component pattern (unrecognised action)")
|
||||||
|
|
||||||
|
if "eval(" in when_block:
|
||||||
|
return (
|
||||||
|
"unsupported",
|
||||||
|
"contains eval() — unsupported dynamic construct",
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_component:
|
||||||
|
return ("component_rule", "component-only pattern (no relation)")
|
||||||
|
|
||||||
|
return (
|
||||||
|
"unsupported",
|
||||||
|
f"patterns: {pattern_types} — no recognised Relation+Component combo",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_drl(
|
||||||
|
file_path: Path,
|
||||||
|
*,
|
||||||
|
parse_body: bool = True,
|
||||||
|
) -> IngestReport:
|
||||||
|
text = file_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
package_name, imports = split_drl(text)
|
||||||
|
raw_rules = parse_rules(text)
|
||||||
|
|
||||||
|
supported: list[IngestedRule] = []
|
||||||
|
unsupported: list[IngestedRule] = []
|
||||||
|
errors: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
for r in raw_rules:
|
||||||
|
name = r["name"]
|
||||||
|
attrs = r["attrs"]
|
||||||
|
when_block = r["when"]
|
||||||
|
then_block = r["then"]
|
||||||
|
|
||||||
|
salience_m = SALIENCE_RE.search(attrs)
|
||||||
|
no_loop = bool(NO_LOOP_RE.search(attrs))
|
||||||
|
|
||||||
|
patterns = parse_patterns(when_block) if parse_body else []
|
||||||
|
actions = parse_actions(then_block) if parse_body else []
|
||||||
|
|
||||||
|
kind, reason = classify_rule(name, when_block, then_block, patterns, actions)
|
||||||
|
|
||||||
|
rule = IngestedRule(
|
||||||
|
name=name,
|
||||||
|
kind=kind,
|
||||||
|
package=package_name,
|
||||||
|
imports=imports,
|
||||||
|
salience=int(salience_m.group(1)) if salience_m else None,
|
||||||
|
no_loop=no_loop,
|
||||||
|
when_block=when_block.strip(),
|
||||||
|
then_block=then_block.strip(),
|
||||||
|
raw_drl=text,
|
||||||
|
source_file=str(file_path),
|
||||||
|
patterns=patterns,
|
||||||
|
actions=actions,
|
||||||
|
support_reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
if kind == "unsupported":
|
||||||
|
unsupported.append(rule)
|
||||||
|
else:
|
||||||
|
supported.append(rule)
|
||||||
|
|
||||||
|
return IngestReport(
|
||||||
|
source_file=str(file_path),
|
||||||
|
package=package_name,
|
||||||
|
total_rules=len(raw_rules),
|
||||||
|
supported=supported,
|
||||||
|
unsupported=unsupported,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_directory(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
glob_pattern: str = "*.drl",
|
||||||
|
recursive: bool = True,
|
||||||
|
parse_body: bool = True,
|
||||||
|
) -> list[IngestReport]:
|
||||||
|
if path.is_file():
|
||||||
|
return [ingest_drl(path, parse_body=parse_body)]
|
||||||
|
|
||||||
|
pattern = f"**/{glob_pattern}" if recursive else glob_pattern
|
||||||
|
reports = []
|
||||||
|
for f in sorted(path.glob(pattern)):
|
||||||
|
if f.is_file():
|
||||||
|
try:
|
||||||
|
reports.append(ingest_drl(f, parse_body=parse_body))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] {f}: {e}", file=sys.stderr)
|
||||||
|
return reports
|
||||||
|
|
||||||
|
|
||||||
|
def format_summary(reports: list[IngestReport]) -> str:
|
||||||
|
total_files = len(reports)
|
||||||
|
total_rules = sum(r.total_rules for r in reports)
|
||||||
|
total_supported = sum(len(r.supported) for r in reports)
|
||||||
|
total_unsupported = sum(len(r.unsupported) for r in reports)
|
||||||
|
|
||||||
|
by_kind: dict[str, int] = {}
|
||||||
|
for r in reports:
|
||||||
|
for s in r.supported:
|
||||||
|
by_kind[s.kind] = by_kind.get(s.kind, 0) + 1
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Files: {total_files}",
|
||||||
|
f"Total rules: {total_rules}",
|
||||||
|
f"Supported: {total_supported}",
|
||||||
|
f"Unsupported: {total_unsupported}",
|
||||||
|
"",
|
||||||
|
"Breakdown by kind:",
|
||||||
|
]
|
||||||
|
for kind, count in sorted(by_kind.items()):
|
||||||
|
lines.append(f" {kind}: {count}")
|
||||||
|
if total_unsupported:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Unsupported rules:")
|
||||||
|
for r in reports:
|
||||||
|
for u in r.unsupported:
|
||||||
|
lines.append(f" {u.source_file}:{u.name} — {u.support_reason}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Ingest IriusRisk DRL files into a structured catalog."
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"path",
|
||||||
|
type=str,
|
||||||
|
help="DRL file or directory containing .drl files",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Output full catalog as JSON (default: summary text)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--no-parse-body",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip detailed pattern/action parsing (faster, header-only)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--flat",
|
||||||
|
action="store_true",
|
||||||
|
help="Flatten all rules into a single array (used with --json)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--glob",
|
||||||
|
type=str,
|
||||||
|
default="*.drl",
|
||||||
|
help="Glob pattern when scanning a directory (default: *.drl)",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
target = Path(args.path)
|
||||||
|
if not target.exists():
|
||||||
|
print(f"Error: {target} does not exist", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
reports = ingest_directory(
|
||||||
|
target,
|
||||||
|
glob_pattern=args.glob,
|
||||||
|
parse_body=not args.no_parse_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not reports:
|
||||||
|
print("No DRL files found.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
if args.flat:
|
||||||
|
all_rules = []
|
||||||
|
for r in reports:
|
||||||
|
all_rules.extend(r.supported)
|
||||||
|
all_rules.extend(r.unsupported)
|
||||||
|
print(json.dumps([asdict(r) for r in all_rules], indent=2))
|
||||||
|
else:
|
||||||
|
print(json.dumps([asdict(r) for r in reports], indent=2))
|
||||||
|
else:
|
||||||
|
print(format_summary(reports))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -65,3 +65,37 @@ class RuleEvaluation:
|
|||||||
applies: bool
|
applies: bool
|
||||||
reasons: list[str]
|
reasons: list[str]
|
||||||
failed: list[str]
|
failed: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
RuleKind = Literal[
|
||||||
|
"relation_rule",
|
||||||
|
"component_rule",
|
||||||
|
"unsupported",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IngestedRule:
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
package: str | None
|
||||||
|
imports: list[str]
|
||||||
|
salience: int | None
|
||||||
|
no_loop: bool
|
||||||
|
when_block: str
|
||||||
|
then_block: str
|
||||||
|
raw_drl: str
|
||||||
|
source_file: str
|
||||||
|
patterns: list[dict] # parsed condition patterns
|
||||||
|
actions: list[dict] # parsed action statements
|
||||||
|
support_reason: str # why supported or not
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IngestReport:
|
||||||
|
source_file: str
|
||||||
|
package: str | None
|
||||||
|
total_rules: int
|
||||||
|
supported: list[IngestedRule]
|
||||||
|
unsupported: list[IngestedRule]
|
||||||
|
errors: list[tuple[str, str]] # rule_name, error_msg
|
||||||
|
|||||||
Reference in New Issue
Block a user