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 +8 -0
- icsprio/__main__.py +4 -0
- icsprio/attack_mapping.py +57 -0
- icsprio/cli.py +117 -0
- icsprio/config.py +97 -0
- icsprio/http.py +50 -0
- icsprio/io_utils.py +39 -0
- icsprio/join.py +123 -0
- icsprio/pipeline.py +126 -0
- icsprio/provenance.py +45 -0
- icsprio/qa.py +142 -0
- icsprio/reference_data/cwe_to_attack_ics.csv +46 -0
- icsprio/scoring.py +105 -0
- icsprio/sources/__init__.py +8 -0
- icsprio/sources/attack_ics.py +85 -0
- icsprio/sources/cisa_ics.py +208 -0
- icsprio/sources/epss.py +85 -0
- icsprio/sources/kev.py +73 -0
- icsprio/sources/vulnrichment.py +152 -0
- icsprio-1.0.0.dist-info/METADATA +191 -0
- icsprio-1.0.0.dist-info/RECORD +26 -0
- icsprio-1.0.0.dist-info/WHEEL +4 -0
- icsprio-1.0.0.dist-info/entry_points.txt +2 -0
- icsprio-1.0.0.dist-info/licenses/AUTHORS.json +27 -0
- icsprio-1.0.0.dist-info/licenses/LICENSE +30 -0
- icsprio-1.0.0.dist-info/licenses/LICENSE-DATA +20 -0
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,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
|