Chapters: 

Warn if major

Typical workflow

# Preview updates without touching the system
sudo dnf update --assumeno | tee /tmp/updates.txt

# High-only siren with your org’s rules
python3 update-siren-high.py /tmp/updates.txt --rules rules.yml

# Gate a pipeline/cron: exit 2 if HIGH found
sudo dnf update --assumeno | python3 update-siren-high.py --rules rules.yml --exit-code

 

# High-only siren with rules


python3 update-siren-high.py /tmp/updates.txt --rules rules.yml


#!/usr/bin/env python3
"""
Update Siren (HIGH only) — with rules.yml support.

Usage:
  sudo dnf update --assumeno | python3 update-siren-high.py --rules rules.yml
  python3 update-siren-high.py /path/to/updates.txt --rules rules.yml
  python3 update-siren-high.py ... --json
  python3 update-siren-high.py ... --exit-code   # exit 2 if HIGH found

rules.yml keys (all optional):
  high_impact_patterns: [regex, ...]
  minor_series_is_high: true|false
  major_bump_is_high: true|false
  danger_words: [ 'EOL', 'ABI', ... ]
  package_overrides:
    <regex>:
      always_high: true|false
      treat_minor_series_as_major: true|false
      treat_patch_as_series: true|false
"""
import sys, re, json, os
from dataclasses import dataclass
from typing import List, Tuple, Dict, Any

DEFAULT_RULES = {
    "high_impact_patterns": [
        r"^kernel", r"^glibc", r"^openssl", r"^systemd", r"^linux-firmware",
        r"^python(3(\.\d+)?)?$", r"^python3(\.\d+)?", r"^pip", r"^setuptools",
        r"^java-.*openjdk", r"^jdk", r"^jre", r"^openjdk"
    ],
    "minor_series_is_high": True,
    "major_bump_is_high": True,
    "danger_words": ["EOL", "end of life", "retire", "kABI", "ABI", "backward-incompatible", "rebase"],
    "package_overrides": {
        # Examples:
        # r"^kernel": {"treat_patch_as_series": False},
        # r"^python3": {"treat_minor_series_as_major": True},
    }
}

def load_rules(path: str) -> Dict[str, Any]:
    if not path:
        return DEFAULT_RULES
    text = open(path, "r", encoding="utf-8").read()
    # Try YAML if available, else JSON, else very simple YAML-ish parser.
    try:
        import yaml  # type: ignore
        data = yaml.safe_load(text)
        if not isinstance(data, dict):
            return DEFAULT_RULES
        return merge_rules(DEFAULT_RULES, data)
    except Exception:
        # Try JSON
        try:
            import json
            data = json.loads(text)
            if not isinstance(data, dict):
                return DEFAULT_RULES
            return merge_rules(DEFAULT_RULES, data)
        except Exception:
            # ultra-simple parser for a subset of YAML:
            return simple_yaml_like_parse(text, DEFAULT_RULES)

def merge_rules(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
    out = dict(base)
    for k, v in override.items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            tmp = dict(out[k])
            tmp.update(v)
            out[k] = tmp
        else:
            out[k] = v
    return out

def simple_yaml_like_parse(text: str, defaults: Dict[str, Any]) -> Dict[str, Any]:
    # Handles only top-level keys mapping to lists of strings or booleans, plus
    # package_overrides as dict of dicts with booleans.
    out = dict(defaults)
    current_key = None
    for raw in text.splitlines():
        line = raw.strip()
        if not line or line.startswith('#'):
            continue
        if ':' in line and not line.startswith('-'):
            k, _, rest = line.partition(':')
            current_key = k.strip()
            if rest.strip() in ('true', 'false'):
                out[current_key] = (rest.strip() == 'true')
            elif rest.strip() == '':
                if current_key not in out:
                    out[current_key] = []
            else:
                out[current_key] = rest.strip()
        elif line.startswith('-') and current_key:
            val = line[1:].strip().strip('"').strip("'")
            if isinstance(out.get(current_key), list):
                out[current_key].append(val)
    return out

@dataclass
class UpdateItem:
    name: str
    old: str
    new: str
    note: str = ""

def parse_lines(lines: List[str]) -> List[UpdateItem]:
    items = []
    arrow = re.compile(r'(?P<name>[A-Za-z0-9._+-]+)[^0-9]*(?P<old>\d[^>\s]*)\s*->\s*(?P<new>\d[^\s]*)')
    rpm_like = re.compile(r'^(?P<name>[A-Za-z0-9._+-]+)-(?P<new>\d[0-9A-Za-z._+-]*)(?:\.[^. ]+){0,2}$')
    for raw in lines:
        line = raw.strip()
        if not line or line.startswith('#'):
            continue
        m = arrow.search(line)
        if m:
            items.append(UpdateItem(m.group('name'), m.group('old'), m.group('new')))
            continue
        m2 = rpm_like.match(line)
        if m2:
            items.append(UpdateItem(m2.group('name'), '', m2.group('new')))
            continue
        parts = line.split()
        if len(parts) >= 2:
            items.append(UpdateItem(parts[0], parts[1] if parts[1] and parts[1][0].isdigit() else '', parts[-1]))
        else:
            items.append(UpdateItem(line, '', ''))
    return items

def version_tuple(v: str) -> Tuple[int, ...]:
    if not v:
        return tuple()
    cleaned = re.split(r'[^0-9]+', v)
    nums = [int(x) for x in cleaned if x.isdigit()]
    return tuple(nums)

def first_diff(vo: Tuple[int, ...], vn: Tuple[int, ...]) -> int:
    for i, (a, b) in enumerate(zip(vo, vn)):
        if a != b:
            return i
    return -1

def compute_bumps(old: str, new: str, overrides: Dict[str, bool]) -> Dict[str, bool]:
    vo = version_tuple(old)
    vn = version_tuple(new)
    if not vo or not vn:
        return {"major": False, "series": False, "patch": False}
    idx = first_diff(vo, vn)
    if idx == -1:
        return {"major": False, "series": False, "patch": False}
    major = (idx == 0)
    series = (idx == 1) or (overrides.get("treat_patch_as_series") and idx == 2)
    patch = (idx >= 2)
    if overrides.get("treat_minor_series_as_major") and series:
        major = True
    return {"major": major, "series": series, "patch": patch}

def match_any(patterns: List[str], text: str) -> bool:
    return any(re.search(p, text, re.IGNORECASE) for p in patterns or [])

def get_overrides(pkg: str, rules: Dict[str, Any]) -> Dict[str, bool]:
    out = {}
    for pat, cfg in (rules.get("package_overrides") or {}).items():
        try:
            if re.search(pat, pkg, re.IGNORECASE):
                if isinstance(cfg, dict):
                    out.update({k: bool(v) for k, v in cfg.items()})
        except re.error:
            continue
    return out

def is_high(item: UpdateItem, rules: Dict[str, Any]) -> bool:
    if not item.new and not item.old:
        return False
    pkg = item.name
    hi = match_any(rules.get("high_impact_patterns") or [], pkg)
    if not hi:
        return False
    ov = get_overrides(pkg, rules)
    bumps = compute_bumps(item.old, item.new, ov)
    if rules.get("major_bump_is_high", True) and bumps["major"]:
        return True
    if rules.get("minor_series_is_high", True) and bumps["series"]:
        return True
    if ov.get("always_high"):
        return True
    if item.note and match_any(rules.get("danger_words") or [], item.note):
        return True
    return False

def main():
    import argparse
    ap = argparse.ArgumentParser(description="Update Siren (HIGH only) with rules.yml")
    ap.add_argument('input', nargs='?', help="Path to text file with updates (e.g., from 'dnf update --assumeno').")
    ap.add_argument('--rules', help="Path to rules.yml (or JSON).")
    ap.add_argument('--json', action='store_true', help="Output JSON.")
    ap.add_argument('--exit-code', action='store_true', help="Exit 2 if HIGH found, else 0.")
    args = ap.parse_args()

    rules = load_rules(args.rules) if args.rules else DEFAULT_RULES

    lines = sys.stdin.readlines() if not args.input else open(args.input, 'r', encoding='utf-8').readlines()
    items = parse_lines(lines)

    highs = []
    for it in items:
        if is_high(it, rules):
            highs.append({
                'package': it.name,
                'old_version': it.old,
                'new_version': it.new,
                'reason': 'HIGH via rules'
            })

    if args.json:
        print(json.dumps(highs, indent=2))
    else:
        if highs:
            print(f"{'PACKAGE':30} {'OLD':18} {'NEW':18} REASON")
            print('-' * 90)
            for r in highs:
                print(f"{r['package'][:30]:30} {r['old_version'][:18]:18} {r['new_version'][:18]:18} {r['reason']}")
        else:
            print("(no HIGH-risk items found)")

    if args.exit_code:
        sys.exit(2 if highs else 0)

if __name__ == '__main__':
    main()

 

 

Rules

So you can change them

# Update Siren rules.yml — sample
high_impact_patterns:
  - '^kernel'
  - '^glibc'
  - '^openssl'
  - '^systemd'
  - '^linux-firmware'
  - '^python(3(\.[0-9]+)?)?$'
  - '^python3(\.[0-9]+)?'
  - '^pip'
  - '^setuptools'
  - '^java-.*openjdk'
  - '^jdk'
  - '^jre'
  - '^openjdk'

minor_series_is_high: true     # e.g., Python 3.10 -> 3.11
major_bump_is_high: true       # e.g., 8 -> 11

danger_words:
  - 'EOL'
  - 'end of life'
  - 'retire'
  - 'kABI'
  - 'ABI'
  - 'backward-incompatible'
  - 'rebase'

package_overrides:
  '^kernel':
    treat_patch_as_series: false
  '^python3':
    treat_minor_series_as_major: true    # escalate 3.x -> 3.(x+1) to MAJOR