icsprio 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
icsprio/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """icsprio: fetch, join, and prioritize ICS vulnerability intelligence.
2
+
3
+ Sources: CISA ICS Advisories, CISA KEV, FIRST.org EPSS, CISA Vulnrichment,
4
+ and MITRE ATT&CK for ICS. See README.md for the full pipeline and
5
+ docs/SCORING.md for the prioritization logic.
6
+ """
7
+
8
+ __version__ = "1.0.0"
icsprio/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,57 @@
1
+ """CWE -> ATT&CK-for-ICS heuristic crosswalk.
2
+
3
+ [VERIFY] There is no authoritative, MITRE-published CVE-to-ATT&CK-for-ICS
4
+ mapping. This module applies a documented heuristic crosswalk
5
+ (icsprio/reference_data/cwe_to_attack_ics.csv, methodology in docs/ATTACK_MAPPING.md;
6
+ it lives inside the package rather than under data/ so it is bundled by `pip install`
7
+ and resolves for a run from any working directory)
8
+ from a vulnerability's CWE weakness class to the ATT&CK-for-ICS techniques an
9
+ adversary would plausibly use to exploit that class of weakness in an OT
10
+ environment. Every mapped row carries a `confidence` (high/medium/low) and a
11
+ one-line `rationale` so a reader can judge it rather than trust it blindly.
12
+ This is icsprio's own analytic contribution, not a MITRE or CISA mapping —
13
+ the author reviews and can edit the crosswalk table before any release.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import csv
19
+ from typing import Dict, List
20
+
21
+ from . import config
22
+
23
+
24
+ def load_crosswalk(path: str = config.CWE_ATTACK_CROSSWALK) -> Dict[str, List[dict]]:
25
+ """Load the crosswalk CSV into a dict keyed by CWE ID (e.g. "CWE-798")."""
26
+ out: Dict[str, List[dict]] = {}
27
+ with open(path, newline="", encoding="utf-8") as f:
28
+ for row in csv.DictReader(f):
29
+ out.setdefault(row["cwe_id"], []).append(
30
+ {
31
+ "attack_technique_id": row["attack_technique_id"],
32
+ "attack_technique_name": row["attack_technique_name"],
33
+ "confidence": row["confidence"],
34
+ "rationale": row["rationale"],
35
+ }
36
+ )
37
+ return out
38
+
39
+
40
+ def map_cwes_to_techniques(cwes: List[str], crosswalk: Dict[str, List[dict]]) -> List[dict]:
41
+ """Return the (deduplicated, technique-id-sorted) mapped techniques for a
42
+ list of CWE IDs on one vulnerability. A CWE absent from the crosswalk
43
+ contributes nothing — it is not an error, just an unmapped weakness class
44
+ (see docs/ATTACK_MAPPING.md for coverage and how to extend it).
45
+ """
46
+ seen: Dict[str, dict] = {}
47
+ for cwe in cwes or []:
48
+ for mapping in crosswalk.get(cwe, []):
49
+ key = mapping["attack_technique_id"]
50
+ existing = seen.get(key)
51
+ if existing is None or _rank(mapping["confidence"]) > _rank(existing["confidence"]):
52
+ seen[key] = mapping
53
+ return sorted(seen.values(), key=lambda m: m["attack_technique_id"])
54
+
55
+
56
+ def _rank(confidence: str) -> int:
57
+ return {"high": 3, "medium": 2, "low": 1}.get(confidence, 0)
icsprio/cli.py ADDED
@@ -0,0 +1,117 @@
1
+ """icsprio command-line interface.
2
+
3
+ icsprio fetch [--since-year YYYY] [--raw-dir data/raw]
4
+ icsprio build [--raw-dir data/raw] [--processed-dir data/processed] [--weights-file weights.yaml]
5
+ icsprio run [--since-year YYYY] [--final]
6
+ icsprio report [--processed-dir data/processed]
7
+
8
+ `run` does fetch + build + QA in one call and is what the README's quick
9
+ start uses; `fetch`/`build` are split out for anyone who wants to re-score
10
+ already-fetched data (e.g. after editing docs/SCORING.md's weights) without
11
+ re-hitting the network.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import sys
18
+
19
+ import click
20
+ import yaml
21
+
22
+ from . import __version__, config, pipeline
23
+ from .config import ScoringWeights
24
+
25
+
26
+ def _load_weights(weights_file: str) -> ScoringWeights:
27
+ if not weights_file:
28
+ return ScoringWeights()
29
+ with open(weights_file, encoding="utf-8") as f:
30
+ data = yaml.safe_load(f) or {}
31
+ weights = ScoringWeights(**data)
32
+ weights.validate()
33
+ return weights
34
+
35
+
36
+ @click.group()
37
+ @click.version_option(__version__, prog_name="icsprio")
38
+ def main():
39
+ """Fetch, join, and prioritize ICS vulnerability intelligence."""
40
+
41
+
42
+ @main.command()
43
+ @click.option("--since-year", type=int, default=None, help="Earliest advisory year to fetch (default: current year - 3).")
44
+ @click.option("--raw-dir", default=config.DEFAULT_RAW_DIR, show_default=True)
45
+ def fetch(since_year, raw_dir):
46
+ """Fetch CISA ICS advisories, KEV, EPSS, Vulnrichment, and ATT&CK for ICS."""
47
+ manifest = pipeline.run_fetch(since_year=since_year, raw_dir=raw_dir)
48
+ click.echo("Fetched:")
49
+ for stage, count in manifest["stage_counts"].items():
50
+ click.echo(f" {stage}: {count}")
51
+
52
+
53
+ @main.command()
54
+ @click.option("--raw-dir", default=config.DEFAULT_RAW_DIR, show_default=True)
55
+ @click.option("--processed-dir", default=config.DEFAULT_PROCESSED_DIR, show_default=True)
56
+ @click.option("--weights-file", default=None, help="YAML file overriding docs/SCORING.md's default weights.")
57
+ @click.option("--final", is_flag=True, help="Remove the DRAFT stamp from the QA report (only after the verification gate passes).")
58
+ def build(raw_dir, processed_dir, weights_file, final):
59
+ """Join, score, and QA-report an already-fetched manifest (run `fetch` first)."""
60
+ manifest_path = os.path.join(raw_dir, "_fetch_manifest.json")
61
+ if not os.path.exists(manifest_path):
62
+ click.echo(f"No fetch manifest at {manifest_path}. Run `icsprio fetch` first.", err=True)
63
+ sys.exit(1)
64
+
65
+ manifest = pipeline.load_manifest(manifest_path)
66
+ weights = _load_weights(weights_file)
67
+ df = pipeline.run_build(manifest, weights=weights)
68
+ stats, report_text = pipeline.run_qa(df, manifest["stage_counts"], draft=not final)
69
+
70
+ from . import io_utils
71
+
72
+ io_utils.export_csv(df, os.path.join(processed_dir, "icsprio_joined.csv"))
73
+ parquet_ok = io_utils.export_parquet(df, os.path.join(processed_dir, "icsprio_joined.parquet"))
74
+ io_utils.write_stats_json(stats, os.path.join(processed_dir, "stats.json"))
75
+ with open(os.path.join(processed_dir, "qa_report.txt"), "w", encoding="utf-8") as f:
76
+ f.write(report_text)
77
+
78
+ click.echo(f"Wrote {len(df)} rows to {processed_dir}/icsprio_joined.csv")
79
+ if not parquet_ok:
80
+ click.echo("(pyarrow not installed — skipped Parquet export; `pip install icsprio[parquet]`)")
81
+ click.echo(f"QA report: {processed_dir}/qa_report.txt")
82
+
83
+
84
+ @main.command()
85
+ @click.option("--since-year", type=int, default=None)
86
+ @click.option("--raw-dir", default=config.DEFAULT_RAW_DIR, show_default=True)
87
+ @click.option("--processed-dir", default=config.DEFAULT_PROCESSED_DIR, show_default=True)
88
+ @click.option("--weights-file", default=None)
89
+ @click.option("--final", is_flag=True)
90
+ def run(since_year, raw_dir, processed_dir, weights_file, final):
91
+ """Fetch + build + QA in one call."""
92
+ weights = _load_weights(weights_file)
93
+ result = pipeline.run_pipeline(
94
+ since_year=since_year,
95
+ raw_dir=raw_dir,
96
+ processed_dir=processed_dir,
97
+ weights=weights,
98
+ draft=not final,
99
+ )
100
+ click.echo(f"Wrote {len(result['df'])} rows to {processed_dir}/icsprio_joined.csv")
101
+ click.echo(f"QA report: {processed_dir}/qa_report.txt")
102
+
103
+
104
+ @main.command()
105
+ @click.option("--processed-dir", default=config.DEFAULT_PROCESSED_DIR, show_default=True)
106
+ def report(processed_dir):
107
+ """Print the most recently generated QA report."""
108
+ path = os.path.join(processed_dir, "qa_report.txt")
109
+ if not os.path.exists(path):
110
+ click.echo(f"No QA report at {path}. Run `icsprio run` first.", err=True)
111
+ sys.exit(1)
112
+ with open(path, encoding="utf-8") as f:
113
+ click.echo(f.read())
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
icsprio/config.py ADDED
@@ -0,0 +1,97 @@
1
+ """Central configuration: source URLs, defaults, and paths.
2
+
3
+ Every network endpoint icsprio talks to is named here, once, so the
4
+ provenance log and the README's source table can point at the same
5
+ constant instead of a URL buried in a fetch function.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+
13
+ # --- Source endpoints (confirmed live 2026-09-13; see BUILD_SPEC.md) ------
14
+
15
+ CISA_ICS_ADVISORIES_INDEX = "https://www.cisa.gov/news-events/ics-advisories"
16
+ # CISA paginates the advisory index; each page also exposes a matching
17
+ # `.json`-view via the site's search API used below.
18
+ CISA_ICS_SEARCH_API = "https://www.cisa.gov/api/search"
19
+
20
+ CISA_KEV_JSON_URL = (
21
+ "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
22
+ )
23
+
24
+ EPSS_API_URL = "https://api.first.org/data/v1/epss"
25
+ EPSS_BATCH_SIZE = 100 # CVEs per request; keeps query strings well under limits
26
+
27
+ # Vulnrichment stores one JSON file per CVE, under <root>/<year>/<Nxxx>/<CVE-ID>.json
28
+ VULNRICHMENT_RAW_ROOT = "https://raw.githubusercontent.com/cisagov/vulnrichment/main"
29
+ VULNRICHMENT_API_ROOT = "https://api.github.com/repos/cisagov/vulnrichment/contents"
30
+
31
+ ATTACK_ICS_STIX_URL = (
32
+ "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/"
33
+ "ics-attack/ics-attack.json"
34
+ )
35
+
36
+ # --- Advisory scope ---------------------------------------------------------
37
+
38
+ # CISA publishes ICS advisories under two prefixes: ICSA (general ICS/OT) and
39
+ # ICSMA (medical devices, ICS-adjacent). icsprio includes both by default,
40
+ # tagged by `advisory_type`, per BUILD_SPEC.md's verify point on scope.
41
+ ADVISORY_TYPES = ("ICSA", "ICSMA")
42
+
43
+ # --- HTTP behavior -----------------------------------------------------------
44
+
45
+ USER_AGENT = "icsprio/1.0 (+https://github.com/friday-ikwuogu/icsprio; contact: Friday.ikwuogu@gmail.com)"
46
+ REQUEST_TIMEOUT_SECONDS = 30
47
+ MAX_RETRIES = 4
48
+
49
+ # --- Default paths ------------------------------------------------------------
50
+
51
+ DEFAULT_RAW_DIR = os.path.join("data", "raw")
52
+ DEFAULT_PROCESSED_DIR = os.path.join("data", "processed")
53
+ PROVENANCE_LOG = os.path.join(DEFAULT_RAW_DIR, "PROVENANCE.txt")
54
+
55
+ # The CWE->ATT&CK-for-ICS crosswalk ships INSIDE the package (icsprio/reference_data/),
56
+ # not under data/, specifically so it resolves correctly for a `pip install`ed
57
+ # copy run from any working directory — a path under data/ would only exist
58
+ # in a cloned repository. Resolved relative to this file, not the process's
59
+ # cwd, so `icsprio run` works the same whether invoked from a repo clone or
60
+ # an arbitrary directory after `pip install icsprio`.
61
+ CWE_ATTACK_CROSSWALK = os.path.join(
62
+ os.path.dirname(os.path.abspath(__file__)), "reference_data", "cwe_to_attack_ics.csv"
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class ScoringWeights:
68
+ """Weights for the deterministic priority_score formula (docs/SCORING.md).
69
+
70
+ All weights are on a common 0-1 scale of "how much this signal should
71
+ move priority"; `compute_priority_score` normalizes the weighted sum to
72
+ 0-100. Changing these values is a VERIFY-gated decision, not a code
73
+ change — see docs/SCORING.md before adjusting.
74
+ """
75
+
76
+ kev_weight: float = 0.40
77
+ epss_weight: float = 0.25
78
+ ssvc_exploitation_weight: float = 0.15
79
+ ssvc_automatable_weight: float = 0.10
80
+ ssvc_technical_impact_weight: float = 0.10
81
+
82
+ def as_dict(self) -> dict:
83
+ return {
84
+ "kev_weight": self.kev_weight,
85
+ "epss_weight": self.epss_weight,
86
+ "ssvc_exploitation_weight": self.ssvc_exploitation_weight,
87
+ "ssvc_automatable_weight": self.ssvc_automatable_weight,
88
+ "ssvc_technical_impact_weight": self.ssvc_technical_impact_weight,
89
+ }
90
+
91
+ def validate(self) -> None:
92
+ total = sum(self.as_dict().values())
93
+ if not (0.99 <= total <= 1.01):
94
+ raise ValueError(
95
+ f"ScoringWeights must sum to 1.0 (+/-0.01); got {total:.4f}. "
96
+ "See docs/SCORING.md before changing weights."
97
+ )
icsprio/http.py ADDED
@@ -0,0 +1,50 @@
1
+ """Shared HTTP session: consistent User-Agent, timeout, and retry policy
2
+ for every source module. Centralizing this means a rate limit or outage on
3
+ one source doesn't need its own bespoke retry loop.
4
+
5
+ Retries are implemented with the standard library only (exponential
6
+ backoff via `time.sleep`) rather than a third-party retry library, so
7
+ icsprio's runtime dependency list stays minimal.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+
14
+ import requests
15
+
16
+ from . import config
17
+
18
+
19
+ def get_session() -> requests.Session:
20
+ session = requests.Session()
21
+ session.headers.update({"User-Agent": config.USER_AGENT, "Accept": "application/json"})
22
+ return session
23
+
24
+
25
+ class FetchError(RuntimeError):
26
+ """Raised when a source cannot be fetched after retries."""
27
+
28
+
29
+ def get(session: requests.Session, url: str, **kwargs) -> requests.Response:
30
+ """GET with exponential-backoff retry on transient network errors.
31
+
32
+ Raises FetchError on a non-2xx response; 4xx/5xx are NOT retried, since
33
+ retrying a bad request or an auth failure wastes the source's
34
+ rate-limit budget without changing the outcome. Only connection and
35
+ timeout errors are retried, up to `config.MAX_RETRIES` attempts.
36
+ """
37
+ kwargs.setdefault("timeout", config.REQUEST_TIMEOUT_SECONDS)
38
+ last_exc = None
39
+ for attempt in range(config.MAX_RETRIES):
40
+ try:
41
+ resp = session.get(url, **kwargs)
42
+ except (requests.ConnectionError, requests.Timeout) as exc:
43
+ last_exc = exc
44
+ if attempt < config.MAX_RETRIES - 1:
45
+ time.sleep(min(2**attempt, 20))
46
+ continue
47
+ if resp.status_code >= 400:
48
+ raise FetchError(f"GET {url} -> HTTP {resp.status_code}: {resp.text[:300]}")
49
+ return resp
50
+ raise FetchError(f"GET {url} failed after {config.MAX_RETRIES} attempts: {last_exc}")
icsprio/io_utils.py ADDED
@@ -0,0 +1,39 @@
1
+ """Export helpers: CSV/Parquet output and the stats.json the docs read from.
2
+
3
+ Per the project's build standard, no document or README should ever have a
4
+ hand-typed number — everything traces back to stats.json, written here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+
12
+ import pandas as pd
13
+
14
+
15
+ def export_csv(df: pd.DataFrame, path: str) -> None:
16
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
17
+ listy_cols = [c for c in df.columns if df[c].apply(lambda v: isinstance(v, list)).any()]
18
+ out = df.copy()
19
+ for col in listy_cols:
20
+ out[col] = out[col].apply(lambda v: "; ".join(v) if isinstance(v, list) else v)
21
+ out.to_csv(path, index=False)
22
+
23
+
24
+ def export_parquet(df: pd.DataFrame, path: str) -> bool:
25
+ """Best-effort Parquet export; returns False (without raising) if pyarrow
26
+ isn't installed, so `icsprio build` doesn't hard-fail over an optional
27
+ dependency."""
28
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
29
+ try:
30
+ df.to_parquet(path, index=False)
31
+ return True
32
+ except ImportError:
33
+ return False
34
+
35
+
36
+ def write_stats_json(stats: dict, path: str) -> None:
37
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
38
+ with open(path, "w", encoding="utf-8") as f:
39
+ json.dump(stats, f, indent=2, sort_keys=True, default=str)
icsprio/join.py ADDED
@@ -0,0 +1,123 @@
1
+ """Join advisories, KEV, EPSS, Vulnrichment, and the ATT&CK-for-ICS crosswalk
2
+ into icsprio's single output table — one row per (advisory, CVE) pair.
3
+
4
+ This is the only place all five sources meet, which is deliberate: every
5
+ other module can be tested against one source in isolation, and this module
6
+ is where join match rates get measured for the QA report.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, List, Optional
12
+
13
+ import pandas as pd
14
+
15
+ from . import attack_mapping
16
+
17
+ OUTPUT_COLUMNS = [
18
+ "advisory_id",
19
+ "advisory_type",
20
+ "title",
21
+ "published",
22
+ "updated",
23
+ "vendors",
24
+ "products",
25
+ "advisory_url",
26
+ "cve",
27
+ "in_kev",
28
+ "kev_date_added",
29
+ "kev_due_date",
30
+ "kev_ransomware_use",
31
+ "epss_score",
32
+ "epss_percentile",
33
+ "epss_date",
34
+ "ssvc_exploitation",
35
+ "ssvc_automatable",
36
+ "ssvc_technical_impact",
37
+ "cvss_base_score",
38
+ "cvss_vector",
39
+ "cwes",
40
+ "attack_technique_ids",
41
+ "attack_technique_names",
42
+ "priority_score",
43
+ "priority_band",
44
+ ]
45
+
46
+
47
+ def build_joined_table(
48
+ advisory_rows: List[dict],
49
+ kev_by_cve: Dict[str, dict],
50
+ epss_by_cve: Dict[str, dict],
51
+ vulnrichment_by_cve: Dict[str, dict],
52
+ attack_techniques: Dict[str, dict],
53
+ crosswalk: Dict[str, List[dict]],
54
+ ) -> pd.DataFrame:
55
+ rows = []
56
+ for base in advisory_rows:
57
+ cve = base.get("cve")
58
+ row = dict(base)
59
+
60
+ kev = kev_by_cve.get(cve, {}) if cve else {}
61
+ row["in_kev"] = kev.get("in_kev", False)
62
+ row["kev_date_added"] = kev.get("kev_date_added")
63
+ row["kev_due_date"] = kev.get("kev_due_date")
64
+ row["kev_ransomware_use"] = kev.get("kev_ransomware_use")
65
+
66
+ epss = epss_by_cve.get(cve, {}) if cve else {}
67
+ row["epss_score"] = epss.get("epss_score")
68
+ row["epss_percentile"] = epss.get("epss_percentile")
69
+ row["epss_date"] = epss.get("epss_date")
70
+
71
+ vr = vulnrichment_by_cve.get(cve, {}) if cve else {}
72
+ row["ssvc_exploitation"] = vr.get("ssvc_exploitation")
73
+ row["ssvc_automatable"] = vr.get("ssvc_automatable")
74
+ row["ssvc_technical_impact"] = vr.get("ssvc_technical_impact")
75
+ row["cvss_base_score"] = vr.get("cvss_base_score")
76
+ row["cvss_vector"] = vr.get("cvss_vector")
77
+ cwes = vr.get("cwes", [])
78
+ row["cwes"] = cwes
79
+
80
+ mapped = attack_mapping.map_cwes_to_techniques(cwes, crosswalk)
81
+ # Keep only techniques actually present in the fetched ATT&CK catalog,
82
+ # so a stale crosswalk entry never claims a technique ID that ATT&CK
83
+ # itself no longer lists.
84
+ mapped = [m for m in mapped if m["attack_technique_id"] in attack_techniques]
85
+ row["attack_technique_ids"] = [m["attack_technique_id"] for m in mapped]
86
+ row["attack_technique_names"] = [m["attack_technique_name"] for m in mapped]
87
+
88
+ rows.append(row)
89
+
90
+ df = pd.DataFrame(rows)
91
+ for col in OUTPUT_COLUMNS:
92
+ if col not in df.columns:
93
+ df[col] = None
94
+ return df[OUTPUT_COLUMNS]
95
+
96
+
97
+ def join_match_rates(df: pd.DataFrame) -> Dict[str, Optional[float]]:
98
+ """Fraction of CVE-bearing rows that matched each enrichment source —
99
+ written into the QA report so a reader can see how complete the join is
100
+ without opening the data.
101
+
102
+ Returns `None` for a source whose column isn't present in `df` at all,
103
+ rather than raising — `build_joined_table`'s output always has every
104
+ column (see OUTPUT_COLUMNS), but this function is also useful for QA'ing
105
+ a partially-assembled or hand-built table that doesn't.
106
+ """
107
+ with_cve = df[df["cve"].notna()]
108
+ n = len(with_cve) or 1
109
+
110
+ def rate(col: str, predicate) -> Optional[float]:
111
+ if col not in with_cve.columns:
112
+ return None
113
+ return float(predicate(with_cve[col]).sum()) / n
114
+
115
+ return {
116
+ "kev_match_rate": rate("in_kev", lambda s: s.astype(bool)),
117
+ "epss_match_rate": rate("epss_score", lambda s: s.notna()),
118
+ "vulnrichment_match_rate": rate("ssvc_exploitation", lambda s: s.notna()),
119
+ "attack_mapped_rate": rate(
120
+ "attack_technique_ids",
121
+ lambda s: s.apply(lambda x: isinstance(x, list) and len(x) > 0),
122
+ ),
123
+ }
icsprio/pipeline.py ADDED
@@ -0,0 +1,126 @@
1
+ """Pipeline orchestration: fetch -> build/join/score -> QA.
2
+
3
+ Kept separate from cli.py so the whole pipeline is callable and testable as
4
+ plain functions, independent of the CLI framework.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from typing import Optional
12
+
13
+ from . import attack_mapping, config, io_utils, join, qa, scoring
14
+ from .config import ScoringWeights
15
+ from .sources import attack_ics, cisa_ics, epss, kev, vulnrichment
16
+
17
+ MANIFEST_PATH = os.path.join(config.DEFAULT_RAW_DIR, "_fetch_manifest.json")
18
+
19
+
20
+ def run_fetch(
21
+ since_year: Optional[int] = None,
22
+ raw_dir: str = config.DEFAULT_RAW_DIR,
23
+ manifest_path: Optional[str] = None,
24
+ ) -> dict:
25
+ """Fetch every source and write a consolidated manifest so `run_build`
26
+ never needs to re-fetch or re-parse raw payloads.
27
+
28
+ `manifest_path` defaults to `<raw_dir>/_fetch_manifest.json` — derived
29
+ from `raw_dir` rather than a fixed global path, so pointing `raw_dir` at
30
+ a scratch directory (as the test suite does) never touches the real
31
+ `data/raw/`.
32
+ """
33
+ if manifest_path is None:
34
+ manifest_path = os.path.join(raw_dir, "_fetch_manifest.json")
35
+
36
+ from .http import get_session
37
+
38
+ session = get_session()
39
+
40
+ print("Fetching CISA ICS advisories (this is the slow step; progress logged every 25)...", flush=True)
41
+ advisories = cisa_ics.fetch_ics_advisories(since_year=since_year, session=session, raw_dir=raw_dir)
42
+ advisory_rows = cisa_ics.advisories_to_rows(advisories)
43
+ cve_ids = sorted({r["cve"] for r in advisory_rows if r.get("cve")})
44
+ print(f" -> {len(advisories)} advisories, {len(cve_ids)} unique CVEs", flush=True)
45
+
46
+ print("Fetching KEV catalog...", flush=True)
47
+ kev_by_cve = kev.fetch_kev(session=session, raw_dir=raw_dir)
48
+ print("Fetching EPSS scores...", flush=True)
49
+ epss_by_cve = epss.fetch_epss(cve_ids, session=session, raw_dir=raw_dir)
50
+ print("Fetching Vulnrichment records (progress logged every 50 CVEs)...", flush=True)
51
+ vulnrichment_by_cve = vulnrichment.fetch_vulnrichment(cve_ids, session=session, raw_dir=raw_dir)
52
+ print("Fetching ATT&CK for ICS...", flush=True)
53
+ attack_techniques = attack_ics.fetch_attack_ics(session=session, raw_dir=raw_dir)
54
+
55
+ manifest = {
56
+ "since_year": since_year,
57
+ "advisory_rows": advisory_rows,
58
+ "kev_by_cve": kev_by_cve,
59
+ "epss_by_cve": epss_by_cve,
60
+ "vulnrichment_by_cve": vulnrichment_by_cve,
61
+ "attack_techniques": attack_techniques,
62
+ "stage_counts": {
63
+ "advisories_fetched": len(advisories),
64
+ "advisory_cve_rows": len(advisory_rows),
65
+ "unique_cves": len(cve_ids),
66
+ "kev_entries": len(kev_by_cve),
67
+ "epss_scored_cves": len(epss_by_cve),
68
+ "vulnrichment_records": len(vulnrichment_by_cve),
69
+ "attack_techniques": len(attack_techniques),
70
+ },
71
+ }
72
+ os.makedirs(os.path.dirname(manifest_path) or ".", exist_ok=True)
73
+ with open(manifest_path, "w", encoding="utf-8") as f:
74
+ json.dump(manifest, f, indent=2, default=str)
75
+ return manifest
76
+
77
+
78
+ def load_manifest(manifest_path: str = MANIFEST_PATH) -> dict:
79
+ with open(manifest_path, encoding="utf-8") as f:
80
+ return json.load(f)
81
+
82
+
83
+ def run_build(
84
+ manifest: dict,
85
+ weights: Optional[ScoringWeights] = None,
86
+ crosswalk_path: str = config.CWE_ATTACK_CROSSWALK,
87
+ ):
88
+ crosswalk = attack_mapping.load_crosswalk(crosswalk_path)
89
+ df = join.build_joined_table(
90
+ advisory_rows=manifest["advisory_rows"],
91
+ kev_by_cve=manifest["kev_by_cve"],
92
+ epss_by_cve=manifest["epss_by_cve"],
93
+ vulnrichment_by_cve=manifest["vulnrichment_by_cve"],
94
+ attack_techniques=manifest["attack_techniques"],
95
+ crosswalk=crosswalk,
96
+ )
97
+ df = scoring.score_dataframe(df, weights=weights)
98
+ return df
99
+
100
+
101
+ def run_qa(df, stage_counts: dict, seed: int = 42, draft: bool = True):
102
+ stats = qa.build_qa_report(df, stage_counts, seed=seed)
103
+ text = qa.render_qa_report_text(stats, draft=draft)
104
+ return stats, text
105
+
106
+
107
+ def run_pipeline(
108
+ since_year: Optional[int] = None,
109
+ raw_dir: str = config.DEFAULT_RAW_DIR,
110
+ processed_dir: str = config.DEFAULT_PROCESSED_DIR,
111
+ weights: Optional[ScoringWeights] = None,
112
+ draft: bool = True,
113
+ ):
114
+ """Fetch, build, score, and QA in one call — what `icsprio run` invokes,
115
+ and what tests exercise end-to-end against fixtures."""
116
+ manifest = run_fetch(since_year=since_year, raw_dir=raw_dir)
117
+ df = run_build(manifest, weights=weights)
118
+ stats, report_text = run_qa(df, manifest["stage_counts"], draft=draft)
119
+
120
+ io_utils.export_csv(df, os.path.join(processed_dir, "icsprio_joined.csv"))
121
+ parquet_ok = io_utils.export_parquet(df, os.path.join(processed_dir, "icsprio_joined.parquet"))
122
+ io_utils.write_stats_json(stats, os.path.join(processed_dir, "stats.json"))
123
+ with open(os.path.join(processed_dir, "qa_report.txt"), "w", encoding="utf-8") as f:
124
+ f.write(report_text)
125
+
126
+ return {"df": df, "stats": stats, "report_text": report_text, "parquet_written": parquet_ok}
icsprio/provenance.py ADDED
@@ -0,0 +1,45 @@
1
+ """Provenance logging: every raw fetch is recorded before it is trusted.
2
+
3
+ Mirrors scripts/provenance.py from the project's build tooling, exposed as
4
+ a library function so fetchers can call it directly instead of shelling out.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ import hashlib
11
+ import os
12
+
13
+
14
+ def sha256_bytes(data: bytes) -> str:
15
+ return hashlib.sha256(data).hexdigest()
16
+
17
+
18
+ def log_fetch(
19
+ log_path: str,
20
+ filename: str,
21
+ content: bytes,
22
+ source_url: str,
23
+ note: str = "",
24
+ ) -> str:
25
+ """Append one provenance line and return it.
26
+
27
+ Format matches scripts/provenance.py so data/raw/PROVENANCE.txt reads
28
+ consistently whether a record was appended by the CLI or by hand.
29
+ """
30
+ os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
31
+ line = " | ".join(
32
+ part
33
+ for part in [
34
+ datetime.date.today().isoformat(),
35
+ filename,
36
+ f"{len(content)} bytes",
37
+ f"sha256:{sha256_bytes(content)}",
38
+ source_url,
39
+ note,
40
+ ]
41
+ if part
42
+ )
43
+ with open(log_path, "a", encoding="utf-8") as f:
44
+ f.write(line + "\n")
45
+ return line