Skip to content
Merged
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/008-erp-bundle-scoped-report"
"feature_directory": "specs/009-affected-products-column"
}
238 changes: 224 additions & 14 deletions .specify/memory/constitution.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,9 @@ to suppress it everywhere.
| `matching.py` | pure verdict logic: release normalisation, model→platform mapping, verdicts | ✅ |
| `psirt.py` | the only module that talks to Cisco's advisory API | ✅ |
| `inventory.py` | read the inventory workbook into per-firewall records | ✅ |
| `erp.py` | the only module that talks to Cisco's Security Center: resolve a bundle page to its advisory identifiers | ✅ |
| `erp.py` | resolve a published bundle page to the advisory identifiers it contains — membership only, never an advisory fact | ✅ |
| `bundle.py` | pure per-advisory aggregation: covered families, filtering, row construction | ✅ |
| `csaf.py` | the only module that reads Cisco's published advisory documents, for an advisory's two product-scope statements and nothing else | ✅ |
| `cli.py` | single entry point; refuses removed options, then dispatches `--help`/`--version`/`--update`/`--uninstall`/`--config` and the run | |
| `analyzer.py` | terminal run flow + interactive prompts (working-folder inventory + `output/`) | |
| `html_report.py` | render the self-contained HTML report, one row per firewall | |
Expand All @@ -458,7 +459,7 @@ to suppress it everywhere.
| `version.py` | installed version, GitHub release discovery, uv-based update | |
| `ui.py` | terminal colors + prompts (no dependencies) | |

**Shared surface.** The six modules marked above are the ones a consumer other than this
**Shared surface.** The seven modules marked above are the ones a consumer other than this
CLI is expected to import; the rest are the terminal program built on top of them. The
entry point is `assessment.analyze(inventory, client)` — it takes a list of per-device
dicts (whatever produced them) plus a `psirt.Client` the caller owns, prints nothing,
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4.2.1
4.3.0
299 changes: 299 additions & 0 deletions brds/006-affected-products-column.md

Large diffs are not rendered by default.

38 changes: 36 additions & 2 deletions caia/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
import sys
from pathlib import Path

from caia import assessment, bundle_report, config, erp, html_report, matching, psirt
from caia import (assessment, bundle_report, config, csaf, erp, html_report, matching,
psirt)
from caia import inventory as inv
from caia import ui

Expand Down Expand Up @@ -219,6 +220,37 @@ def fetch_missing_records(client, bundle, devices):
return records


def fetch_scope_statements(bundle, devices, records, opener=None):
"""Cisco's own product-scope statements for every advisory in the bundle (spec 009).

Unauthenticated, and it sends nothing about any device — the advisory identifier inside the URL
Cisco's API supplied is the only value that leaves the machine.

Never fails a run. A statement that could not be retrieved renders as an explicit cause, so the
report says what it does not know rather than leaving a cell a reader would take as "Cisco
affects no products" (FR-026, FR-029). Progress is reported because ~25 sequential retrievals is
long enough that silence reads as a hang (FR-047).
"""
from caia import bundle as bundle_logic

targets = bundle_logic.scope_targets(bundle.advisory_ids, devices, records=records)
if not targets:
return {}
ui.plain()
ui.system(f"Reading Cisco's product-scope statements for {len(targets)} "
f"advisor{'y' if len(targets) == 1 else 'ies'} ...")

def report(index, total, advisory_id):
ui.plain(ui.dim(f" [{index}/{total}] {advisory_id}"))

scopes = csaf.retrieve_many(targets, on_progress=report, opener=opener)
missing = csaf.missing_count(scopes, bundle.advisory_ids)
if missing:
ui.info(f"{missing} advisor{'y' if missing == 1 else 'ies'} could not supply a "
"product-scope statement; the report says so per advisory.")
return scopes


# --------------------------------------------------------------------------- #
# Run flow
# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -299,13 +331,15 @@ def report_progress(index, total, check):

# 6. Report — bundle-scoped when a bundle was resolved, whole-fleet otherwise.
records = fetch_missing_records(client, scope, devices) if scope else {}
scopes = fetch_scope_statements(scope, devices, records) if scope else None
try:
ensure_dirs(output_dir)
if scope:
out = bundle_report.build_and_write(
scope, devices, records=records, output_dir=str(output_dir),
questions_asked=client.request_count, partial_cause=partial_cause,
generated_at=summary["generated_at"], inventory_file=inv_path.name)
generated_at=summary["generated_at"], inventory_file=inv_path.name,
scopes=scopes)
else:
out = html_report.write_report(devices, summary, str(output_dir))
except OSError as e:
Expand Down
30 changes: 30 additions & 0 deletions caia/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ def cves(self):
def url(self):
return str(self.record.get("publicationUrl") or "")

@property
def csaf_url(self):
"""Where Cisco publishes this advisory's machine-readable document.

Read from the record, never constructed from the identifier. That is what keeps the scope
statements a *followed reference* rather than a second source the tool went looking for —
the basis on which the constitution admits them at all (spec 009 FR-019).
"""
return str(self.record.get("csafUrl") or "")

def accounts(self):
"""True when the row's three groupings account for its population exactly once."""
return (len(self.impacted) + len(self.unaffected) + len(self.undetermined)
Expand Down Expand Up @@ -296,6 +306,26 @@ def missing_records(bundle_advisory_ids, devices):
return [a for a in bundle_advisory_ids if a not in index]


def scope_targets(bundle_advisory_ids, devices, records=None):
"""`{advisory_id: csaf_url}` for every advisory in the bundle. Pure (spec 009 FR-019).

Lets the terminal layer retrieve the scope statements before rendering, so the report module
never performs network I/O.

Every bundle advisory gets an entry, including one whose record is missing — it maps to `""`,
which the retrieval resolves to an explicit "Cisco published no content" rather than dropping the
advisory. An advisory silently absent from this mapping would render as a blank cell, and a blank
cell reads as "no products affected" (FR-026).
"""
index = index_advisories(devices)
targets = {}
for advisory_id in bundle_advisory_ids:
record = ((index.get(advisory_id) or {}).get("record")
or (records or {}).get(advisory_id) or {})
targets[advisory_id] = str(record.get("csafUrl") or "")
return targets


def suppressed_count(bundle_advisory_ids, devices):
"""Distinct advisories affecting the inventory that this bundle does not list (FR-016).

Expand Down
Loading
Loading