

How to automatically query vendor patch feeds, compare them against your OT asset inventory, and generate actionable alerts.
| Item | Why you need it | Recommended version |
|---|---|---|
| Vendor API access (Schneider Electric EcoStruxure Patch Service, Siemens Industrial Security Feed) | Pulls the latest CVE‑to‑patch mapping and severity scores. | API key provided by vendor portal |
| Python 3.9+ | Core language for the reference implementation. | python --version |
| Node.js 18+ (LTS) | For the JavaScript/TypeScript sample. | node --version |
| Git | To clone the example repo (optional). | git --version |
| IDE / Text editor | VS Code, PyCharm, WebStorm, etc. | Any |
| Basic knowledge of OT asset inventory (CSV, JSON, or CMDB) | You’ll compare vendor data against what you actually run. | — |
| (Optional) Docker | To run the samples in an isolated container. | Docker 20.10+ |
Note: The code below uses mock endpoints (
https://api.example.com/vendor/patches) that mimic the real Schneider Electric and Siemens feeds. Replace the base URL and authentication method with the official vendor APIs when you go live.
git clone https://github.com/icarax/ics-patch-tuesday-guide.git
cd ics-patch-tuesday-guide
# Create a virtual environment (recommended)
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
# Install dependencies
pip install --upgrade pip
pip install requests python-dotenv tqdm
# Initialize a new npm project (if you didn't clone the repo)
npm init -y
# Install core libraries
npm install axios dotenv
# Install TypeScript and type definitions (if you want TS)
npm install --save-dev typescript @types/node ts-node
npx tsc --init # creates a basic tsconfig.json
ics-patch-tuesday-guide/
│
├─ python/
│ ├─ main.py
│ ├─ .env
│ └─ requirements.txt
│
├─ js/
│ ├─ src/
│ │ └─ index.ts
│ ├─ .env
│ ├─ package.json
│ └─ tsconfig.json
│
└─ README.md
Both language samples follow the same logical flow:
{ vendor, product, version, cve, severity, patchUrl }.product matches and the asset’s installedVersion is vulnerable (i.e., < fixedVersion).Below are the fully‑working, copy‑paste‑ready snippets.
python/main.py)#!/usr/bin/env python3
"""
ICS Patch Tuesday – Vendor feed aggregation & vulnerability check
=================================================================
* Reads Schneider Electric & Siemens patch feeds (mock endpoints)
* Compares against a local CSV inventory of OT assets
* Prints a JSON report of vulnerable assets and suggested remediation
"""
import os
import csv
import json
import logging
from typing import List, Dict, Any
from urllib.parse import urljoin
import requests
from dotenv import load_dotenv
from tqdm import tqdm
# ----------------------------------------------------------------------
# Configuration & helpers
# ----------------------------------------------------------------------
load_dotenv() # loads .env into os.environ
SCHNEIDER_BASE = os.getenv("SCHNEIDER_API_BASE", "https://api.example.com/schneider")
SIEMENS_BASE = os.getenv("SIEMENS_API_BASE", "https://api.example.com/siemens")
API_KEY = os.getenv("VENDOR_API_KEY") # shared key for both mock services
INVENTORY_FILE = os.getenv("INVENTORY_FILE", "assets.csv")
REPORT_FILE = os.getenv("REPORT_FILE", "vulnerability_report.json")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
log = logging.getLogger(__name__)
def _auth_headers() -> Dict[str, str]:
"""Vendor‑specific auth – adjust per real API."""
return {"Authorization": f"Bearer {API_KEY}", "Accept": "application/json"}
def fetch_patch_feed(base_url: str) -> List[Dict[str, Any]]:
"""
Pull the patch feed from a vendor.
Expected JSON shape (mock):
[
{
"vendor": "Schneider Electric",
"product": "Modicon M340",
"version": "2.4.1",
"cve": "CVE-2024-12345",
"severity": "Critical",
"fixedVersion": "2.4.2",
"patchUrl": "https://support.schneider-electric.com/patch/..."
},
...
]
"""
url = urljoin(base_url, "/patches")
log.info(f"Fetching patch feed from {url}")
try:
resp = requests.get(url, headers=_auth_headers(), timeout=15)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
raise ValueError("Expected a JSON list")
log.info(f"Received {len(data)} patch entries from {base_url}")
return data
except requests.RequestException as exc:
log.error(f"HTTP error while fetching {url}: {exc}")
return [] # fail‑soft – you may want to raise instead
except ValueError as exc:
log.error(f"Invalid JSON from {url}: {exc}")
return []
def load_inventory(csv_path: str) -> List[Dict[str, str]]:
"""
Simple CSV inventory. Expected columns:
asset_id, vendor, product, installedVersion, location, criticality
"""
assets = []
try:
with open(csv_path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
assets.append({k.strip(): v.strip() for k, v in row.items()})
log.info(f"Loaded {len(assets)} assets from {csv_path}")
except FileNotFoundError:
log.error(f"Inventory file not found: {csv_path}")
except Exception as exc:
log.error(f"Failed to read inventory: {exc}")
return assets
def version_lt(v1: str, v2: str) -> bool:
"""
Very naive semantic‑version comparison – sufficient for the demo.
For production use `packaging.version` (pip install packaging).
"""
def normalize(v):
return [int(x) for x in v.split(".") if x.isdigit()]
return normalize(v1) < normalize(v2)
def check_vulnerabilities(
patches: List[Dict[str, Any]],
assets: List[Dict[str, str]]
) -> List[Dict[str, Any]]:
"""
Return a list of findings:
{
"asset_id": "...",
"vendor": "...",
"product": "...",
"installedVersion": "...",
"cve": "...",
"severity": "...",
"fixedVersion": "...",
"patchUrl": "..."
}
"""
findings = []
# Index patches by (vendor, product) for faster lookup
patch_index: Dict[tuple, List[Dict[str, Any]]] = {}
for p in patches:
key = (p.get("vendor", "").strip().lower(), p.get("product", "").strip().lower())
patch_index.setdefault(key, []).append(p)
for asset in tqdm(assets, desc="Scanning assets"):
key = (
asset.get("vendor", "").strip().lower(),
asset.get("product", "").strip().lower(),
)
installed = asset.get("installedVersion", "")
if not installed:
continue
for patch in patch_index.get(key, []):
fixed = patch.get("fixedVersion")
if fixed and version_lt(installed, fixed):
findings.append({
"asset_id": asset.get("asset_id"),
"vendor": asset.get("vendor"),
"product": asset.get("product"),
"installedVersion": installed,
"cve": patch.get("cve"),
"severity": patch.get("severity"),
"fixedVersion": fixed,
"patchUrl": patch.get("patchUrl"),
})
# Break after first matching patch – you may want to list all
break
return findings
def main() -> None:
# 1️⃣ Pull feeds
schneider_patches = fetch_patch_feed(SCHNEIDER_BASE)
siemens_patches = fetch_patch_feed(SIEMENS_BASE)
all_patches = schneider_patches + siemens_patches
# 2️⃣ Load inventory
assets = load_inventory(INVENTORY_FILE)
# 3️⃣ Correlate
vulns = check_vulnerabilities(all_patches, assets)
# 4️⃣ Output report
report = {
"generatedAt": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"totalAssetsScanned": len(assets),
"totalVulnerabilitiesFound": len(vulns),
"findings": vulns,
}
with open(REPORT_FILE, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
log.info(f"Report written to {REPORT_FILE} ({len(vulns)} findings)")
if __name__ == "__main__":
main()
Explanation of key parts
| Section | What it does |
|---|---|
_auth_headers() | Builds the bearer token header – replace with API‑key query param or mutual TLS if your vendor requires it. |
fetch_patch_feed() | Calls the vendor’s /patches endpoint, validates JSON, returns a list of patch dicts. Errors are logged and return an empty list (fail‑soft). |
load_inventory() | Reads a CSV asset list; you can swap this for a DB query or CMDB API call. |
version_lt() | Simple version compare – for production replace with packaging.version.parse. |
check_vulnerabilities() | Core correlation logic: indexes patches by (vendor, product) then scans assets. |
main() | Orchestrates the flow and writes a JSON report. |
Tip: If you have a large inventory (>10 k assets), consider using a database (SQLite/Postgres) and performing the join via SQL for better performance.
js/src/index.ts)#!/usr/bin/env node
/**
* ICS Patch Tuesday – Node.js/TS version
* Mirrors the Python logic but uses axios + dotenv.
*/
import * as dotenv from "dotenv";
import axios from "axios";
import * as fs from "fs";
import * as path from "path";
import { parse } from "csv-parse/sync"; // npm i csv-parse
dotenv.config();
interface PatchEntry {
vendor: string;
product: string;
version: string; // vulnerable version advertised in feed
cve: string;
severity: string;
fixedVersion: string;
patchUrl: string;
}
interface Asset {
asset_id: string;
vendor: string;
product: string;
installedVersion: string;
location?: string;
criticality?: string;
}
interface Finding extends Asset {
cve: string;
severity: string;
fixedVersion: string;
patchUrl: string;
}
// ----------------------------------------------------------------------
// Config
// ----------------------------------------------------------------------
const SCHNEIDER_BASE = process.env.SCHNEIDER_API_BASE ?? "https://api.example.com/schneider";
const SIEMENS_BASE = process.env.SIEMENS_API_BASE ?? "https://api.example.com/siemens";
const API_KEY = process.env.VENDOR_API_KEY ?? "";
const INVENTORY_FILE = process.env.INVENTORY_FILE ?? path.resolve(__dirname, "../../assets.csv");
const REPORT_FILE = process.env.REPORT_FILE ?? path.resolve(__dirname, "../../vulnerability_report.json");
if (!API_KEY) {
console.error("❌ Vendor API key not set – check .env");
process.exit(1);
}
const axiosInstance = axios.create({
timeout: 15000,
headers: {
Authorization: `Bearer ${API_KEY}`,
Accept: "application/json",
},
});
// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------
async function fetchPatchFeed(baseUrl: string): Promise<PatchEntry[]> {
const url = new URL("/patches", baseUrl).toString();
console.log(`🔎 Fetching patch feed from ${url}`);
try {
const { data } = await axiosInstance.get<PatchEntry[]>(url);
console.log(`✅ Received ${data.length} patches from ${baseUrl}`);
return data;
} catch (err: any) {
if (axios.isAxiosError(err)) {
console.error(`❌ HTTP error fetching ${url}: ${err.response?.status ?? err.message}`);
} else {
console.error(`❌ Unexpected error: ${err}`);
}
return []; // fail‑soft
}
}
function loadInventory(csvPath: string): Asset[] {
if (!fs.existsSync(csvPath)) {
console.error(`❌ Inventory file not found: ${csvPath}`);
return [];
}
const fileContent = fs.readFileSync(csvPath, { encoding: "utf8" });
const records = parse(fileContent, {
columns: true,
skip_empty_lines: true,
trim: true,
}) as Asset[];
console.log(`📦 Loaded ${records.length} assets from ${csvPath}`);
return records;
}
/**
* Very naive semver compare – replace with `semver` package in prod.
*/
function lt(v1: string, v2: string): boolean {
const normalize = (v: string) => v.split(".").map(Number);
return normalize(v1).some((n, i) => n < (normalize(v2)[i] ?? 0));
}
function checkVulnerabilities(
patches: PatchEntry[],
assets: Asset[]
): Finding[] {
// Index patches by (vendor, product) → list
const patchMap = new Map<
string,
PatchEntry[]
>();
for const p of patches {
const key = `${p.vendor.toLowerCase()}|${p.product.toLowerCase()}`;
patchMap.set(key, (patchMap.get(key) ?? []).concat(p));
}
const findings: Finding[] = [];
for (const asset of assets) {
const key = `${asset.vendor.toLowerCase()}|${asset.product.toLowerCase()}`;
const installed = asset.installedVersion;
if (!installed) continue;
const candPatches = patchMap.get(key) ?? [];
for (const p of candPatches) {
if (p.fixedVersion && lt(installed, p.fixedVersion)) {
findings.push({
...asset,
cve: p.cve,
severity: p.severity,
fixedVersion: p.fixedVersion,
patchUrl: p.patchUrl,
});
// break after first match – remove if you want all
break;
}
}
}
return findings;
}
// ----------------------------------------------------------------------
// Main
// ----------------------------------------------------------------------
(async function main() {
console.log("🚀 Starting ICS Patch Tuesday aggregation...");
const [schneiderPatches, siemensPatches] = await Promise.all([
fetchPatchFeed(SCHNEIDER_BASE),
fetchPatchFeed(SIEMENS_BASE),
]);
const allPatches = [...schneiderPatches, ...siemensPatches];
const assets = loadInventory(INVENTORY_FILE);
const vulns = checkVulnerabilities(allPatches, assets);
const report = {
generatedAt: new Date().toISOString(),
totalAssetsScanned: assets.length,
totalVulnerabilitiesFound: vulns.length,
findings: vulns,
};
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2), "utf8");
console.log(
`🗒️ Report written to ${REPORT_FILE} (${vulns.length} findings)`
);
})();
Explanation of key parts
| Section | What it does |
|---|---|
dotenv.config() | Loads .env into process.env. |
axiosInstance | Pre‑configured Axios with bearer token and timeout. |
fetchPatchFeed() | Calls the vendor endpoint, returns PatchEntry[]; logs errors and returns empty array on failure. |
loadInventory() | Uses csv-parse/sync to read CSV into typed Asset[]. |
lt() | Simple semantic‑version less‑than; replace with semver (npm i semver) for prod. |
checkVulnerabilities() | Same indexing‑then‑scan logic as Python version. |
main() | Orchestrates async fetch, correlation, and writes a JSON report. |
Note: The mock endpoints return an array where each entry contains both the vulnerable version (
version) and the fixed version (fixedVersion). Adjust field names if your actual vendor API differs.
Create a .env file at the root of each language folder (or a shared location) with the following variables:
# -------------------------------------------------
# Vendor API credentials (shared for both examples)
# -------------------------------------------------
VENDOR_API_KEY=your-super-secret-api-key-here
# Base URLs – replace with the real vendor endpoints when available
SCHNEIDER_API_BASE=https://api.example.com/schneider
SIEMENS_API_BASE=https://api.example.com/siemens
# Path to your OT asset inventory (CSV format)
INVENTORY_FILE=./assets.csv
# Where the final JSON report should be written
REPORT_FILE=./vulnerability_report.json
assets.csvasset_id,vendor,product,installedVersion,location,criticality
OT-001,Schneider Electric,Modicon M340,2.4.1,Plant A,High
OT-002,Siemens,S7-1500 CPU,2.6.0,Plant B,Medium
OT-003,Schneider Electric,Altivar 71,3.2.0,Plant C,Low
OT-004,Siemens,SCALANCE X200,4.1.2,Plant D,High
Tip: Keep the CSV under version control only if it contains no sensitive data (e.g., IP addresses, credentials). For production, pull assets from a CMDB or asset‑management API instead.
| Pattern | Description | Code snippet (Python) | Code snippet (TS) |
|---|---|---|---|
| Retry with exponential backoff | Handles transient network glitches when calling vendor APIs. | python\nimport time, random\nfor attempt in range(3):\n try:\n resp = requests.get(url, headers=hdrs, timeout=10)\n resp.raise_for_status()\n break\n except requests.RequestException as e:\n wait = 2 ** attempt + random.random()\n time.sleep(wait)\nelse:\n raise RuntimeError("All retries exhausted")\n | ts\nimport { delay } from 'rxjs';\nasync function fetchWithRetry(url:string, attempts=3):Promise<any>{\n for(let i=0;i<attempts;i++){\n try{ return await axiosInstance.get(url); }\n catch(e:any){\n if(i===attempts-1) throw e;\n await new Promise(r=>setTimeout(r, 2**i*1000));\n }\n }\n}\n |
| Asset enrichment via CMDB | After finding a vulnerability, pull extra context (owner, SLA) from your CMDB. | python\nimport requests\ncmdb_url = f\"https://cmdb.example.com/api/assets/{asset_id}\"\ncmdb_data = requests.get(cmdb_url, headers=cmdb_hdr).json()\nenriched = {**finding, **cmdb_data}\n | ts\nconst cmdbResp = await axiosInstance.get(`https://cmdb.example.com/api/assets/${asset.asset_id}`);\nconst enriched = { ...finding, ...cmdbResp.data };\n |
| Reporting to Slack / Teams | Push a concise message when any Critical finding appears. | ```python\nimport json\nslack_webhook = os.getenv('SLACK_WEBHOOK')\nif slack_webhook and any(f['severity']=='Critical' for f in vulns):\n payload = {\n 'text': f':rotating_light: {len([f for f in vulns if f["severity"]=="Critical"])} Critical ICS patches missing!',\n 'attachments': [{'color':'danger','fields':[{'title':'Report','value':f'<{REPORT_FILE} | view>'}]}]\n }\n requests.post(slack_webhook, json=payload)\n``` |
| Scheduled execution (cron / Cloud Scheduler) | Run the script daily after vendor Patch Tuesday release. | Add a line to crontab: 0 2 * * 2 /path/to/venv/bin/python /path/to/main.py >> /var/log/ics_patch.log 2>&1 | Use node-cron or cloud scheduler: cron.schedule('0 2 * * 2', () => require('./dist/index.js')); |
| Idempotent run with state file | Avoid re‑alerting on the same finding unless the patch version changes. | Store last‑seen CVE set in a JSON file and compare before emitting. | Same idea – read/write a state.json file. |
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized from vendor API | Wrong or missing VENDOR_API_KEY; token expired. | Verify the key in .env. If the vendor uses OAuth, implement token refresh flow. |
Empty patch list ([]) | Network block, wrong base URL, or vendor endpoint changed. | Test URL manually with curl or Postman. Ensure outbound HTTPS (port 443) is allowed from the host running the script. |
CSV parsing error: inconsistent number of columns | Inventory file has extra commas or quoted fields not handled. | Use a proper CSV library (csv in Python, csv-parse in Node). Or re‑export the asset list from your CMDB with consistent quoting. |
| Version comparison false‑negatives (vulnerable asset reported as safe) | Using naive version_lt on non‑numeric versions like 2.4.1a. | Replace with packaging.version (Python) or semver (Node). Example: from packaging.version import parse as V; return V(v1) < V(v2). |
| Report file not written | Lack of write permission in the target directory. | Run the script as a user with write rights, or specify an absolute path with proper permissions (/var/log/ics_patch/). |
| Duplicate findings each run | No state tracking; script reports same CVE every execution. | Implement a simple state file: store the SHA‑256 of each finding; only emit new ones. |
| High CPU / memory usage on large inventories | O(N*M) naïve loop without indexing. | Index patches by (vendor,product) as shown; for >100k assets consider loading both sets into a temporary SQLite DB and run a SQL JOIN. |
Debugging tip: Set LOG_LEVEL=DEBUG (Python) or export DEBUG=* (Node) to see the raw HTTP responses and intermediate data structures.
| ✅ Item | Why it matters | How to verify |
|---|---|---|
| Secure secret management | API keys must never be hard‑coded. | Use a secret manager (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) and inject via environment variables or IAM roles. |
| TLS verification | Prevent man‑in‑the‑middle on API calls. | Ensure requests/axios defaults to verify=True (Python) / rejectUnauthorized:true (Node). Do not disable cert checks. |
| Input validation & sanitization | CSV or CMDB data could contain malicious values. | Validate that asset_id, vendor, product match allowed regex patterns; cast versions to semver objects; reject unexpected fields. |
| Idempotent & safe retries | Avoid spamming vendors or creating duplicate alerts. | Implement exponential backoff with jitter; cap max retries (e.g., 5). |
| Observability | You need to know when the job succeeds/fails. | Emit structured logs (JSON) to a central system (Splunk, ELK, CloudWatch). Export metrics: assets_scanned, vulnerabilities_found, http_errors. |
| Alert deduplication | Prevent alert fatigue. | Store a hash of each finding (e.g., sha256(asset_id+cve)) in a Redis set or DB; only notify if not seen in the last X days. |
| Version‑safe comparison | Incorrect version logic leads to missed or false alerts. | Use a proven library: packaging.version (Python) or semver (Node). Write unit tests covering edge cases (1.0, 1.0.0, 1.0.0-rc.1, 2.0.0+build). |
| Secure storage of reports | Reports may contain internal asset details. | Write reports to an access‑controlled bucket (S3 with bucket policy, Azure Blob with SAS, GCS with IAM). Encrypt at rest. |
| Patch‑validation step (optional) | Ensure the suggested fix actually exists and is downloadable. | HEAD request to patchUrl before including it in the report; log failures separately. |
| Run‑as least‑privilege | Limits impact if the process is compromised. | Execute under a dedicated service account with only: outbound HTTPS to vendor APIs, read access to inventory source, write access to report destination. |
| Testing in staging | Catch regressions before prod. | Deploy the same code to a non‑prod environment that mirrors network policies and uses a mock vendor API (e.g., WireMock, Mountebank). |
| Documentation & runbook | Operators need to know how to respond to alerts. | Include a short runbook: “If Critical finding → open change request → apply vendor patch → verify via OT‑specific validation script → close ticket.” |
| License compliance | Third‑party libraries must be approved. | Run pip-licenses or npm license-checker; ensure all dependencies are permissible for internal use. |
Populate your .env with real vendor keys and endpoints.
Place your current OT asset inventory as assets.csv (or adapt the loader to hit your CMDB API).
Run the script:
# Python
python python/main.py
# TypeScript (after build)
npm run build # compiles ts -> js
node dist/index.js
Check vulnerability_report.json for any findings.
Integrate the output into your ticketing system, SIEM, or automated patch‑management workflow.
Stay safe, keep those PLCs patched, and remember: defence in depth starts with knowing exactly what you have—and what’s missing. 🚀
Source: Security Week AI
Follow ICARAX for more AI insights and tutorials.
