diff --git a/.env.example b/.env.example
index 477ad75..25cdc88 100644
--- a/.env.example
+++ b/.env.example
@@ -1,18 +1,13 @@
-# The normal way to set your key is: caia --config
-# (it stores these values per-user, so every run in any folder finds them).
+# The normal way to set your credentials is: caia --config
+# (it stores them per-user, so every run in any folder finds them).
#
# This file is only for automation or a local per-folder override: copy it to ".env" in the
# folder you run the tool from, and these values are loaded into the environment for that run.
# Environment variables take precedence over the stored per-user config.
#
-# Get your key from the Fuel iX Dev Portal: https://dev.fuelix.ai
-# -> Default project -> "..." -> View -> API keys -> copy the key.
+# Get your own client ID and secret from the Cisco PSIRT developer portal:
+# https://developer.cisco.com/psirt/ -> register an application -> copy both values.
+# One per person; the client ID is an identifier, the client secret is a secret.
-FUELIX_API_KEY=
-
-# Optional. The model the analyzer sends advisories to.
-# Change only if your org has a different model id enabled (see https://dev.fuelix.ai).
-FUELIX_MODEL=claude-sonnet-5
-
-# Optional. OpenAI-compatible base URL (only change if instructed).
-FUELIX_BASE_URL=https://api.fuelix.ai/v1
+CISCO_CLIENT_ID=
+CISCO_CLIENT_SECRET=
diff --git a/.gitignore b/.gitignore
index f4d1956..db55caa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,6 @@ venv/
# Inputs / outputs
inventory.xlsx
-analysis_output_*.xlsx
impact_report_*.html
~$*.xlsx
@@ -22,9 +21,6 @@ impact_report_*.html
/inventory/
/output/
-# Temp CSAF downloads (created via tempfile)
-csaf_*/
-
# macOS
.DS_Store
diff --git a/.specify/feature.json b/.specify/feature.json
index f7e88b1..ff19045 100644
--- a/.specify/feature.json
+++ b/.specify/feature.json
@@ -1,3 +1,3 @@
{
- "feature_directory": "specs/006-api-inventory-check"
+ "feature_directory": "specs/007-decommission-ai-analyzer"
}
diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md
index e3232f6..8b89c22 100644
--- a/.specify/memory/constitution.md
+++ b/.specify/memory/constitution.md
@@ -1,30 +1,60 @@
-# Cisco Advisory Impact Agent Constitution
+# Cisco Advisory Impact Analyzer Constitution
## Core Principles
### I. Standard-Library-First & Minimal Dependencies
-All network access (HTTP to Cisco and to FueliX) and core logic MUST use the Python
-standard library. A third-party package MAY be added ONLY when the standard library
-cannot reasonably provide the capability, and each such dependency MUST be justified in
-the pull request that introduces it. The runtime dependency set is deliberately small
-(currently `openpyxl` for Excel and `python-dotenv` for `.env` loading); growing it is a
-reviewed decision, not a default.
+All network access (HTTP to Cisco) and core logic MUST use the Python standard library. A
+third-party package MAY be added ONLY when the standard library cannot reasonably provide the
+capability, and each such dependency MUST be justified in the pull request that introduces it.
+The runtime dependency set is deliberately small (currently `openpyxl` to read the user's
+inventory spreadsheet and `python-dotenv` for `.env` loading); growing it is a reviewed
+decision, not a default, and a dependency that outlives its purpose MUST be removed.
Rationale: A small, standard-library-based footprint keeps the tool portable, trivially
installable behind corporate proxies, and low in supply-chain attack surface — essential
@@ -80,51 +110,53 @@ gives every platform the same install/run/update experience with no manual envir
### III. CLI-First, Scriptable Interface
Every capability MUST be reachable through the command line. The tool MUST support both
-an interactive mode and a fully flag-driven mode (e.g. `--url`, `--inventory`,
-`--output-dir`, `--dry-run`) so it can be automated and tested without human input.
-Human-readable output goes to the user via the `ui` module; errors MUST be actionable
-(state what failed and how to fix it). Exit codes MUST be meaningful (non-zero on
-failure; `130` on interrupt).
+an interactive mode and a fully flag-driven mode (e.g. `--inventory`, `--sheet`,
+`--output-dir`) so it can be automated without human input. Human-readable output goes to the
+user via the `ui` module; errors MUST be actionable (state what failed and how to fix it).
+Exit codes MUST be meaningful (non-zero on failure; `130` on interrupt). An option that is
+withdrawn MUST be refused with a message naming it as removed and stating what to run instead,
+rather than falling through to a generic argument error.
Rationale: Scriptability enables automation and repeatable tests; clear text I/O keeps
-the tool debuggable and honest about what it did.
+the tool debuggable and honest about what it did. A withdrawn option is a promise the tool
+once made, so breaking it deserves an explanation rather than a shrug.
### IV. Conservative & Traceable Analysis (NON-NEGOTIABLE)
Impact determinations MUST NOT overstate risk. When the available data is insufficient
-to decide — for example when impact depends on runtime configuration the inventory does
-not track, or when the authoritative source does not recognize a device's software
-release or hardware platform — the result MUST be labelled as undecided, never guessed.
-The label MUST be unambiguous within the report it appears in; `Indeterminate` in the
-advisory-driven analysis and `Needs review` in the inventory-driven check are both
-conforming. A determination that a device is *not* affected MUST rest on positive
-confirmation from official Cisco source data, never on the mere absence of a result; an
-undecided device MUST NOT be presented as unaffected. Every determination MUST be
-traceable to official Cisco source data (an ERP page, an advisory's CSAF JSON, or Cisco's
-PSIRT advisory API); the tool MUST NOT invent advisory facts. Where a mode uses AI to
-reach a determination, that output MUST be normalized and validated before it reaches the
-report.
+to decide — for example when Cisco does not recognize a device's software release, does not
+list that release for the device's hardware platform, or cannot be reached — the result MUST be
+labelled `Needs review`, never guessed. A determination that a device is *not* affected MUST
+rest on positive confirmation from official Cisco source data, never on the mere absence of a
+result; a device labelled `Needs review` MUST NOT be presented as unaffected, and every such
+device MUST carry a human-readable reason. Every determination MUST be traceable to official
+Cisco source data — the PSIRT advisory API and the platform and release catalogues it publishes;
+the tool MUST NOT invent advisory facts, infer a verdict Cisco has not given, or derive one from
+any source other than Cisco's published data.
Rationale: People act on these reports to protect firewalls. A false "Not Affected" is
dangerous and a fabricated "Affected" erodes trust; conservative, source-anchored
-results are the only acceptable posture. Each mode may choose the clearest label for its
-own report, but the substance is fixed: an undecidable device is never presented as safe,
-and safety is asserted only when the source data affirms it. The positive-confirmation
-rule exists because an authoritative source can return "nothing found" both for a device
-that is genuinely clean and for one it cannot recognize at all — treating those alike
-would silently convert a data-quality problem into a clean bill of health.
+results are the only acceptable posture. The positive-confirmation rule exists because an
+authoritative source can return "nothing found" both for a device that is genuinely clean and
+for one it cannot recognize at all — treating those alike would silently convert a data-quality
+problem into a clean bill of health. Requiring the verdict to come from Cisco rather than from
+inference is what makes each finding reproducible and defensible to the person who has to act
+on it.
### V. Secrets Hygiene & Data Locality
API keys and other secrets MUST live only in the per-user configuration file (or in
environment variables) and MUST NEVER be committed, logged, or printed. The per-user
configuration file MUST be stored outside any working folder and, where the OS supports
it, restricted to owner-only permissions. The tool MUST refuse to run when a required
-secret is missing rather than proceeding insecurely. Inventory data MUST stay local; only
-the minimum advisory and inventory context required for a determination may be sent to the
-FueliX AI endpoint. `.gitignore` MUST continue to exclude secrets, inventory, and
-generated reports.
+secret is missing rather than proceeding insecurely, and MUST make no external request in that
+case. Inventory data MUST stay local: the only inventory-derived facts that may leave the
+machine are the product family, hardware platform, and software release being checked — never a
+firewall name, a priority, or any other identifying detail. `.gitignore` MUST continue to
+exclude secrets, inventory, and generated reports.
Rationale: The inventory describes an organization's security posture and the API key is
-a personal credential; leaking either is a direct harm.
+a personal credential; leaking either is a direct harm. Bounding the disclosure to three derived
+values means an observer of the tool's traffic learns what software is being asked about, but
+nothing about who runs it or where.
## Technology & Data Constraints
@@ -135,28 +167,20 @@ a personal credential; leaking either is a direct harm.
source checkout (into a virtual environment) is a supported fallback where `uv` is unavailable;
`--update` is uv-specific and does not apply on that path. The product version is single-sourced
from the committed `VERSION` file into the package metadata at build time.
-- Source of truth: Cisco Event Response (ERP) pages and official advisory CSAF JSON
- fetched from `sec.cloudapps.cisco.com`, and Cisco's PSIRT openVuln advisory API — the
- same published data that backs Cisco's public Software Checker — for release- and
- platform-scoped advisory lookups and for the platform/release catalogues those lookups
- are validated against. Catalogues MUST be retrieved from Cisco rather than hardcoded, so
- newly published releases and platforms are picked up without a tool release. No scraped
- or cached-as-authoritative alternatives.
-- AI provider: FueliX (OpenAI-compatible Chat Completions endpoint) accessed over the
- standard library, using the current default model configured in `fuelix.py`.
+- Source of truth: Cisco's PSIRT openVuln advisory API — the same published data that backs
+ Cisco's public Software Checker — for release- and platform-scoped advisory lookups and for the
+ platform/release catalogues those lookups are validated against. Catalogues MUST be retrieved
+ from Cisco rather than hardcoded, so newly published releases and platforms are picked up
+ without a tool release. No scraped or cached-as-authoritative alternatives.
- Inputs/outputs: the inventory is a user-provided `.xlsx` (sheet `FW_List`) located in the
- current working folder; reports are written into an `output/` folder inside that same
- working folder as timestamped files — `analysis_output_*.xlsx` for the advisory-driven
- analysis and `impact_report_*.html` for the inventory-driven check. An HTML report MUST be
- a single self-contained file that renders with no network access, so it survives being
- emailed or archived. Reports MUST never be overwritten. Header matching MUST stay tolerant
- of minor naming variation.
-- Networking: the analyzer itself contacts only `sec.cloudapps.cisco.com` (Cisco
- ERP/CSAF), `api.fuelix.ai` (AI), `apix.cisco.com` (Cisco PSIRT advisory data and
+ current working folder; the report is written into an `output/` folder inside that same
+ working folder as a timestamped `impact_report_*.html` file. The report MUST be a single
+ self-contained file that renders with no network access, so it survives being emailed or
+ archived. Reports MUST never be overwritten. Header matching MUST stay tolerant of minor
+ naming variation. Advisory content received from Cisco MUST be treated as untrusted text that
+ cannot alter the report's structure, styling, or behaviour.
+- Networking: the analyzer itself contacts only `apix.cisco.com` (Cisco PSIRT advisory data and
platform/release catalogues) and `id.cisco.com` (short-lived access token for that API).
- To the Cisco advisory API the tool MUST send only the derived product family, hardware
- platform and software release being checked — never a firewall name or any other inventory
- identifier — keeping that disclosure strictly smaller than what the AI endpoint receives.
Version and update checks additionally contact
GitHub — `api.github.com` and `github.com` — solely to discover the latest published
release; they send no inventory or secrets and MUST be best-effort and time-bounded so
@@ -181,12 +205,13 @@ constitutional compliance is enforced.
## Development Workflow & Quality Gates
-- Tests: extraction and parsing logic (CSAF product sections, affected releases,
- inventory matching) MUST have tests, and the existing test suite MUST pass before a
- change is merged. Changes to CSAF/ERP parsing or inventory matching MUST add or update
- tests covering the new behavior.
-- Dry-run: user-facing changes that affect analysis flow SHOULD be exercisable via
- `--dry-run` (no AI call, no network write) to keep verification cheap.
+- Tests: the logic behind every verdict — inventory loading and column matching, release
+ normalization, platform and release catalogue handling, verdict assignment, and report
+ generation — MUST have tests, and the existing test suite MUST pass before a change is merged.
+ Changes to any of that logic MUST add or update tests covering the new behavior. Because the
+ tool offers no offline preview and every run contacts Cisco, the test suite is the primary
+ means of verifying an analysis-flow change without credentials: such changes MUST be covered
+ by tests that run with no credentials and no network access.
- Review: pull requests MUST confirm compliance with the Core Principles, especially
Principle IV (conservative, traceable analysis) and Principle V (secrets/data
handling). Any new runtime dependency MUST be explicitly justified (Principle I).
@@ -195,7 +220,7 @@ constitutional compliance is enforced.
those flows change. Their instructions MUST remain consistent with each other and with
the code — including the working-folder inventory and `output/` conventions and where
files are read and written. A change to the instructions in one MUST be mirrored in the
- other within the same pull request.
+ other within the same pull request. Neither may describe a capability the tool does not have.
- Versioning & releases: the committed `VERSION` file is the single source of truth for the
product version and MUST follow semantic versioning. A release is cut by pushing a git tag
equal to the current `VERSION` and publishing a matching GitHub Release; the Release workflow
@@ -208,7 +233,7 @@ constitutional compliance is enforced.
## Governance
-This constitution supersedes ad-hoc practice for the Cisco Advisory Impact Agent. All
+This constitution supersedes ad-hoc practice for the Cisco Advisory Impact Analyzer. All
changes MUST comply with the Core Principles; when a change cannot, the reviewer MUST
require either a compliant alternative or an explicit, documented justification recorded
in the pull request.
@@ -223,4 +248,4 @@ Compliance is verified at review time. Dependent Spec Kit artifacts (plan, spec,
tasks templates and their generated documents) MUST remain consistent with the principles
above; the Constitution Check in the planning workflow enforces this on each feature.
-**Version**: 2.2.1 | **Ratified**: 2026-07-14 | **Last Amended**: 2026-07-25
+**Version**: 3.0.0 | **Ratified**: 2026-07-14 | **Last Amended**: 2026-07-26
diff --git a/README.md b/README.md
index 24c5082..3f2a20c 100644
--- a/README.md
+++ b/README.md
@@ -2,19 +2,18 @@
-Cisco Advisory Impact Agent
+Cisco Advisory Impact Analyzer
-Agentic firewall impact analysis for Cisco security advisories
+Fleet-wide firewall impact analysis, straight from Cisco
---
-Paste a Cisco **Event Response (ERP)** link and get back a spreadsheet telling you
-**which of your firewalls are affected**.
+Run one command in the folder holding your firewall inventory and get back an HTML report
+telling you **which of your firewalls Cisco lists as vulnerable, and to what**.
-Behind the scenes it finds every security advisory referenced by the ERP, downloads each
-advisory's official data (CSAF), and uses AI (via **FueliX**) to compare the affected
-products and software releases against **your firewall inventory**. The result is a
-timestamped Excel file listing, per advisory, the impacted firewalls.
+It asks Cisco's **PSIRT advisory API** — the same published data behind Cisco's public
+Software Checker — which advisories apply to each firewall's software release on its exact
+hardware model, and reports the answer. Every verdict comes from Cisco; nothing is inferred.
Installed as a **[uv](https://docs.astral.sh/uv/) tool**: one install command, then a single
`caia` command you can run from any folder. Works on **macOS,
@@ -28,14 +27,12 @@ Windows, and Linux**.
| - | ----------- | ------------- |
| 1 | **uv** (recommended) — or just **Python 3.9+** | It manages Python for you. Not sure if you have it? See [Get uv](#get-uv) below — check first, install only if needed. Can't install uv? See [Install without uv](#install-without-uv-python-only) — you only need Python. |
| 2 | **Git** | The uv install command below pulls the tool straight from GitHub, so Git must be installed first. Not sure if you have it? See [Get Git](#get-git) below. |
-| 3 | **A FueliX API key** — for advisory mode | Get yours from — each person needs their own (steps below). |
-| 4 | **Cisco PSIRT API credentials** — for inventory mode (`--api`) | A client ID and client secret from — each person needs their own. |
-| 5 | **Your firewall inventory** as an Excel `.xlsx` file in the folder you run from | You build this yourself (format below). |
-| 6 | **Internet access** to `sec.cloudapps.cisco.com` and `api.fuelix.ai`, plus `apix.cisco.com` and `id.cisco.com` for `--api` | Usually already available; corporate proxies can block these. |
+| 3 | **Cisco PSIRT API credentials** | A client ID and client secret from — each person needs their own (steps below). |
+| 4 | **Your firewall inventory** as an Excel `.xlsx` file in the folder you run from | You build this yourself (format below). |
+| 5 | **Internet access** to `apix.cisco.com` and `id.cisco.com` | Usually already available; corporate proxies can block these. |
-> Every credential is stored once, per user, via `caia --config`. You only need the ones
-> for the mode you use: **advisory mode** needs the FueliX key, **inventory mode** needs the
-> Cisco credentials. Each mode refuses to run without its own, and points you at `--config`.
+> Your credentials are stored once, per user, via `caia --config`. The tool refuses to run
+> without them and points you at `--config`.
### Get uv
@@ -140,15 +137,14 @@ repository to clone and no virtual environment to create or activate.
> no need to re-run `--config`. Note that `caia --update` cannot make this jump for you: the
> distribution name changed, so an old install has to be replaced with the command above.
-Then set your FueliX API key once (stored per-user, used from any folder):
+Then set your Cisco credentials once (stored per-user, used from any folder):
```bash
caia --config
```
-`--config` asks for your API key (typing is hidden) and lets you pick the AI model from a short
-list (default `claude-sonnet-5`). The values are saved to a per-user config file — see
-[Configuration](#configuration-your-api-key) below.
+`--config` asks for your Cisco client ID and client secret, checks them against Cisco, and
+saves them to a per-user config file — see [Configuration](#configuration) below.
---
@@ -181,7 +177,7 @@ py -m pip install .
This installs the `caia` command **inside that environment**. From then
on it behaves exactly like the uv install — use it as described in [Run it](#run-it) and
-[Configuration](#configuration-your-api-key) (`caia --config`, then
+[Configuration](#configuration) (`caia --config`, then
`caia` from your inventory folder).
**Notes for this path:**
@@ -193,41 +189,29 @@ on it behaves exactly like the uv install — use it as described in [Run it](#r
- **Updating is manual here.** `--update` is uv-specific and will report that uv wasn't found. To
update, download the newer ZIP and re-run `python -m pip install .` in your environment. Pass
`--no-update-check` to skip the start-of-run version check.
-- Everything else — `--config`, inventory discovery, the `output/` folder, `--dry-run`, and the
- analysis itself — works identically to the uv install.
+- Everything else — `--config`, inventory discovery, the `output/` folder, and the check
+ itself — works identically to the uv install.
---
## Run it
-There are **two modes**, and they answer opposite questions. Pick the one that matches what
-you're asking:
-
-| | Advisory mode (default) | Inventory mode (`--api`) |
-| --- | --- | --- |
-| **Question** | "Given this advisory, which of my firewalls are affected?" | "Given my inventory, which firewalls are vulnerable right now, and to what?" |
-| **You supply** | An ERP or advisory link | Nothing but the inventory |
-| **Answer comes from** | AI reading the advisory's text | Cisco's own published data — no AI |
-| **Output** | `analysis_output_.xlsx` | `impact_report_.html` |
-| **Needs** | FueliX API key | Cisco PSIRT client ID + secret |
-
-The output formats differ because the reports are read differently. The spreadsheet is one
-row per advisory, which suits a per-advisory review. The HTML report is one row per firewall
-with clickable advisory links, collapsible detail and filtering — which is what a
-fleet-wide worklist needs, and what a spreadsheet handles badly once a single device has
-dozens of advisories against it.
-
-### Inventory mode — check everything at once
-
-`cd` into the folder holding your inventory, then:
+`cd` into the folder holding your inventory `.xlsx`, then:
```bash
-caia --api
+caia
```
-No link needed. The tool asks Cisco which published advisories apply to each firewall's
-software release on its exact hardware model, then writes
-**`impact_report__.html`** into the **`output`** folder. Open it in any browser.
+That's the whole thing. No link to paste, no mode to choose. The tool finds your inventory in
+that folder (if there's more than one candidate, it lists them and lets you pick), asks Cisco
+which published advisories apply to each firewall's software release on its exact hardware
+model, and writes **`impact_report__.html`** into an **`output`** folder inside
+your current folder (created automatically). Open it in any browser.
+
+Each run adds a new timestamped file, so previous reports are kept. Press **Ctrl+C** at any
+time to cancel cleanly.
+
+### Understanding the output
Every firewall gets exactly one status:
@@ -243,6 +227,9 @@ Cisco never shipped (e.g. ASA 9.18 on 5500-X hardware). The tool names which, so
becomes a data-quality fix rather than a mystery. A device that could not be checked is
never reported as safe.
+The report is a single self-contained file: it filters, sorts and expands with no network
+access, so it survives being emailed or archived.
+
Two limits are stated in every report, and worth knowing up front:
- Cisco's data holds **no advisories published before January 2022** for ASA, FMC, FTD and
@@ -251,56 +238,20 @@ Two limits are stated in every report, and worth knowing up front:
"listed as affected", not "exploitable".
A run paces itself to stay inside Cisco's request quota, so a large inventory takes a few
-minutes; progress is printed as it goes. Add `--dry-run` to see exactly what would be asked
-without contacting Cisco at all.
-
-### Advisory mode — check one advisory
-
-`cd` into the folder that holds your inventory `.xlsx`, then:
-
-```bash
-caia
-```
+minutes; progress is printed as it goes.
-The tool finds your inventory in that folder (if there's more than one candidate, it lists them
-and lets you pick), then **prompts for the Cisco ERP link**, for example:
-
-```
-https://sec.cloudapps.cisco.com/security/center/viewErp.x?alertId=ERP-75736
-```
-
-(A single advisory link — `.../CiscoSecurityAdvisory/cisco-sa-...` — also works.)
-
-After you confirm, it analyzes each advisory against your inventory and writes a file named
-**`analysis_output__.xlsx`** into an **`output`** folder inside your current folder
-(created automatically). Each run adds a new timestamped file, so previous reports are kept.
-
-Press **Ctrl+C** at any time to cancel cleanly.
-
-### Understanding the output
-
-The spreadsheet has three columns:
-
-| Column | What it means |
-| ------ | ------------- |
-| **Vendor Advisory#** | the advisory id (e.g. `cisco-sa-...`) |
-| **Effected Product Description** | a short summary of the affected products |
-| **Expected Assessment** | the impacted firewalls (one `FirewallName` per line), or `Indeterminate` / `Not Affected` |
-
-- **A list of firewall names** — those devices match the affected product and software
- release; review them.
-- **`Not Affected`** — the advisory applies to ASA/FTD, but none of your devices are on an
- affected release.
-- **`Indeterminate`** — impact can't be decided from the inventory alone. Usually because
- it depends on runtime configuration you don't track (High Availability, VPN/SAML, file
- policy, Snort settings, …) or because it only affects products you don't have (FMC,
- cdFMC, SCC, Snort). These need a manual look.
+> **Coming from version 3.x?** The advisory-driven mode — `caia --url `, which used
+> AI to read one advisory and produced an Excel file — **has been removed in 4.0.0**, along
+> with `--api`, `--dry-run`, `--keep-temp` and `--no-keep-temp`. What `--api` used to do is now
+> what plain `caia` does. If you pass any of the removed options the tool tells you so and what
+> to run instead. You no longer need a FueliX API key; any leftover FueliX settings in your
+> config file are ignored and disappear the next time you run `--config`.
---
## Keeping it up to date
-New releases improve the advisory-matching logic, so it's worth staying current:
+New releases improve the matching logic, so it's worth staying current:
```bash
caia --version # print your version; note if a newer one exists
@@ -329,9 +280,9 @@ caia --uninstall --yes # skip the prompt (for scripts / fleet cleanup)
```
This removes the `caia` command and its uv-managed environment. **Your saved settings are kept** —
-the per-user config file (see [Configuration](#configuration-your-api-key) for its exact location)
-is never touched, so your API key is preserved; on success the tool prints the path and you can
-delete that file yourself if you want it gone.
+the per-user config file (see [Configuration](#configuration) for its exact location)
+is never touched, so your Cisco credentials are preserved; on success the tool prints the path
+and you can delete that file yourself if you want it gone.
- `--uninstall` is uv-specific. If you installed [without uv](#install-without-uv-python-only), it
reports there's nothing to uninstall — remove it with the Python tooling you used
@@ -344,9 +295,9 @@ delete that file yourself if you want it gone.
---
-## Configuration (your API keys)
+## Configuration
-`caia --config` stores your settings in a per-user file, so every run
+`caia --config` stores your credentials in a per-user file, so every run
in any folder can find them:
| OS | Config file |
@@ -358,26 +309,15 @@ in any folder can find them:
The file is `KEY=value` and looks like this (you may edit it by hand):
```
-FUELIX_API_KEY=your-key-here
-FUELIX_MODEL=claude-sonnet-5
-FUELIX_BASE_URL=https://api.fuelix.ai/v1
CISCO_CLIENT_ID=your-cisco-client-id
CISCO_CLIENT_SECRET=your-cisco-client-secret
```
-- **`FUELIX_API_KEY`** — your personal key from the Dev Portal (steps below). Needed by
- advisory mode only.
-- **`FUELIX_MODEL`** (optional) — chosen from a menu during `--config`; default `claude-sonnet-5`.
-- **`FUELIX_BASE_URL`** (optional) — the API endpoint; **not** prompted by `--config`. Change it
- only by editing the file or setting the environment variable, and only if told to.
-- **`CISCO_CLIENT_ID`** / **`CISCO_CLIENT_SECRET`** — your personal Cisco PSIRT API
- credentials (steps below). Needed by inventory mode (`--api`) only.
+- **`CISCO_CLIENT_ID`** — identifies the calling application. Not a secret.
+- **`CISCO_CLIENT_SECRET`** — authenticates it. Handled as a secret throughout: never
+ displayed, logged, or written anywhere but this file.
-`--config` walks through everything in one pass and **every prompt is skippable** — press
-Enter to leave a value alone. If you only ever use one mode, you never have to invent a
-credential for the other. Each mode checks only its own credentials at run time.
-
-Two details of the Cisco prompts worth knowing:
+**Every prompt is skippable** — press Enter to leave a value alone. Two details worth knowing:
- The **client ID is shown as you type or paste it** (it identifies the application; it isn't
a secret), while the **client secret is never displayed**. After you enter the secret the
@@ -388,9 +328,9 @@ Two details of the Cisco prompts worth knowing:
If Cisco simply can't be reached — a proxy or connectivity problem — it says so plainly and
offers to save them unverified, so an outage never blocks setup.
-Precedence for every setting is **environment variable → config file → built-in default**, so
-the same names in the environment still work for automation. On macOS/Linux the config file is
-created owner-only (`chmod 600`). Keep it private — it contains your keys.
+Precedence for every setting is **environment variable → config file**, so the same names in
+the environment still work for automation. On macOS/Linux the config file is created
+owner-only (`chmod 600`). Keep it private — it contains your client secret.
### Getting your Cisco PSIRT API credentials
@@ -400,15 +340,6 @@ created owner-only (`chmod 600`). Keep it private — it contains your keys.
Each person uses **their own** credentials — don't share them.
-### Getting your FueliX API key
-
-1. Go to **** and log in.
-2. You should already have a **Default** project. Click its **`...`** menu, then **View**.
-3. Under **API keys**, a key should already be listed. **Copy** it.
-4. Paste it when `caia --config` asks for it.
-
-Each person uses **their own** key — don't share keys.
-
---
## Troubleshooting
@@ -416,34 +347,39 @@ Each person uses **their own** key — don't share keys.
- **`caia: command not found`** — the tool's bin directory isn't on
your PATH. Run `uv tool update-shell` (or restart your terminal), or check
`uv tool list`.
-- **`No FueliX API key is configured`** — run `caia --config` to set
- your key, or export `FUELIX_API_KEY`.
+- **`No Cisco API credentials are configured`** — run `caia --config` to set your client ID
+ and client secret, or export `CISCO_CLIENT_ID` and `CISCO_CLIENT_SECRET`.
- **`No valid inventory .xlsx found`** — run the tool from the folder that holds your inventory
`.xlsx` (sheet `FW_List`), or pass `--inventory PATH`.
- **`--update` says uv wasn't found** — uv isn't on the tool's PATH. Re-run the
`uv tool install … --from git+…` command from a shell where `uv` works.
-- **Everything comes back `Indeterminate`** — either the advisories are genuinely
- config-dependent / FMC-only, or the tool couldn't reach `sec.cloudapps.cisco.com`. Run with
- `--dry-run` to see what was downloaded without calling the AI.
-- **`FueliX API error` (4xx)** — check your API key is correct and that `FUELIX_MODEL` is a
- model your org has enabled (in the FueliX Dev Portal, ).
+- **Everything comes back `Needs review`** — the report says why for each row. Most often the
+ inventory's release strings or models don't match what Cisco publishes; fix those and re-run.
+- **`Cisco rejected the configured credentials`** — re-check them with `caia --config`; it
+ verifies them against Cisco and shows Cisco's own reason for a rejection.
+- **`Could not reach Cisco`** — check your connection or proxy. The tool needs
+ `apix.cisco.com` and `id.cisco.com`.
+- **`--api has been removed`** (or `--url`, `--dry-run`, `--keep-temp`, `--no-keep-temp`) —
+ these options were withdrawn in 4.0.0. Just run `caia`.
---
## Advanced / automation
-The analysis is fully flag-driven for scripting — supplying an input skips its prompt, and a
-fully-specified run has no prompts at all (no update offer, no confirmation):
+The check is fully flag-driven for scripting — supplying an input skips its prompt, and a
+non-interactive run has no prompts at all (no update offer, no confirmation):
| Flag | Purpose |
| ---- | ------- |
-| `--url ` | supply the ERP/advisory URL instead of being prompted |
| `--inventory ` | use a specific inventory file, bypassing folder discovery |
| `--sheet ` | inventory sheet name (default `FW_List`) |
| `--output-dir ` | where to write the report (default: `./output`) |
-| `--dry-run` | run everything **without** calling the AI (no API key needed) — handy to test setup |
-| `--no-keep-temp` | delete the downloaded CSAF files when finished |
| `--no-update-check` | skip the start-of-run version check |
+| `--yes` | skip the uninstall confirmation |
+
+Exit codes are script-friendly: `0` success, `1` a declined confirmation, `2` an argument
+error (including a removed option), `3` a failed version lookup, `4` a failed update or
+uninstall, `130` interrupted.
On startup the tool prints a splash banner. It is shown only on an interactive terminal, so
piped or redirected output (e.g. `caia --version > file`) stays clean; set `CAIA_NO_BANNER=1`
@@ -460,23 +396,23 @@ to suppress it everywhere.
| Module | Responsibility |
| ------ | -------------- |
-| `cli.py` | single entry point; dispatches `--help`/`--version`/`--update`/`--uninstall`/`--config` and the run |
+| `cli.py` | single entry point; refuses removed options, then dispatches `--help`/`--version`/`--update`/`--uninstall`/`--config` and the run |
| `analyzer.py` | run flow + interactive prompts (working-folder inventory + `output/`) |
-| `cisco.py` | fetch ERP/CSAF, discover advisories, extract sections + affected versions |
-| `inventory.py` | read the inventory, collapse it into distinct model/type/version combos |
-| `fuelix.py` | OpenAI-compatible FueliX client + per-advisory assessment |
-| `report.py` | write the styled Excel report |
+| `inventory.py` | read the inventory workbook into per-firewall records |
+| `psirt.py` | the only module that talks to Cisco's advisory API |
+| `matching.py` | pure verdict logic: release normalisation, model→platform mapping, verdicts |
+| `html_report.py` | render the self-contained HTML report |
| `config.py` | per-user configuration (paths, precedence, `--config`) |
| `version.py` | installed version, GitHub release discovery, uv-based update |
| `ui.py` | terminal colors + prompts (no dependencies) |
The version is single-sourced from the committed `VERSION` file into the package metadata and
-reported at runtime via `importlib.metadata`. To keep the AI prompt small and reliable, the
-inventory rows are collapsed into distinct `(model, type, version)` combos; the AI returns which
-combos are impacted and the tool expands them back into firewall names.
+reported at runtime via `importlib.metadata`. Devices sharing a product family, hardware
+platform and software release collapse into one question to Cisco, so a large inventory costs
+far fewer requests than it has rows.
**Handing it to teammates:** point them at the install command above. Each teammate supplies
-their own API key (`--config`) and inventory.
+their own Cisco credentials (`--config`) and inventory.
**Local development:** install from a checkout with `uv tool install --from . caia-cli --force`, and run the tests with `python -m unittest discover -s tests`. See `CONTRIBUTING.md` for the release process.
diff --git a/VERSION b/VERSION
index 4a36342..fcdb2e1 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.0.0
+4.0.0
diff --git a/brds/004-decommission-fuelix-ai-analyzer.md b/brds/004-decommission-fuelix-ai-analyzer.md
new file mode 100644
index 0000000..a9c30a9
--- /dev/null
+++ b/brds/004-decommission-fuelix-ai-analyzer.md
@@ -0,0 +1,333 @@
+# Business Requirements Document (BRD): Decommission the FueliX AI Analyzer — the Cisco API Check Becomes the Only Mode
+
+## Document Control
+
+| Field | Value |
+| ----------------- | ------------------------------------------------------------ |
+| BRD ID | BRD-004 |
+| Title | Decommission the FueliX AI Analyzer (`caia` becomes the inventory-driven Cisco API check) |
+| Author | Ali Bahaloo / TELUS Digital |
+| Status | Draft |
+| Version | 0.1.0 |
+| Created | 2026-07-26 |
+| Last updated | 2026-07-26 |
+| Related documents | Repo: https://github.com/xavient/caia-cli · BRD-003 (Inventory-Driven Vulnerability Check via the Cisco PSIRT API): `brds/003-api-inventory-check.md` · BRD-001 (Self-Update Mechanism): `brds/001-auto-updater.md` · BRD-002 (Self-Uninstall Command): `brds/002-uninstall.md` · Constitution: `.specify/memory/constitution.md` · Spec 006: `specs/006-api-inventory-check/spec.md` · Cisco PSIRT openVuln API: https://developer.cisco.com/docs/psirt/ |
+
+## 1. Executive Summary
+
+The tool ships two modes today: an advisory-driven analysis that asks a FueliX-hosted AI model to read one Cisco advisory and infer which firewalls it affects, and an inventory-driven check (`caia --api`, delivered by BRD-003) that asks Cisco's own PSIRT API, per device, which advisories apply. The second mode answers the more useful question, from the authoritative source, deterministically. This BRD retires the first one entirely. After this change there is exactly one mode: running `caia` checks the whole inventory against Cisco's published advisory data and writes the HTML impact report. The AI capability is removed root and branch — the FueliX client, the advisory-URL flow, the Excel report, the AI credential and its configuration prompts, every documentation claim of AI-driven or agentic analysis, and every constitutional rule that assumed an AI provider existed. The product is renamed **Cisco Advisory Impact Analyzer**, keeping the `caia` command and the `caia-cli` distribution unchanged, and the product version moves to **4.0.0** to mark the removal.
+
+## 2. Business Context & Problem Statement
+
+BRD-003 shipped a check that gets its verdicts from Cisco directly. That made the older AI-driven mode the weaker of the two on every axis that matters for a security decision, while the cost of keeping it is paid continuously.
+
+- **Inference where an authority exists.** The AI mode reads an advisory's "Vulnerable Products" prose and infers version overlap against the inventory. Cisco publishes the per-platform, per-release answer — and the release that fixes it. Keeping an inferential path alongside an authoritative one invites the wrong one to be used, and leaves two differently-derived answers to reconcile when they disagree.
+- **A per-person credential and a third-party dependency for no unique benefit.** Every user must obtain and configure a FueliX API key before the default command works at all. That is the first thing a new user hits, and it buys them a result the Cisco path produces without it.
+- **Inventory context leaves the organisation.** The AI mode sends advisory text plus the inventory's model/type/version combinations to an external AI endpoint. The Cisco path sends strictly less — a derived family, platform, and release, never a firewall name. Removing the AI mode removes the larger disclosure entirely.
+- **Double surface, permanently.** Two modes mean two credential sets, two report formats (`analysis_output_*.xlsx` and `impact_report_*.html`), two undecided labels (`Indeterminate` and `Needs review`), two documented setup paths, and a mode-comparison table a reader must work through before they can run anything. Roughly 25 FueliX references in `README.md` and 7 on the landing page exist only to serve the mode being retired.
+- **The description is now wrong.** The product is presented as "Cisco Advisory Impact **Agent**" with the tagline "Agentic firewall impact analysis" and an "AI impact analysis" feature card. Once the AI path is gone, that misdescribes the tool to every reader — and for a security tool, overstating how the answer is reached is a trust problem, not a marketing one.
+- **Governance is out of step.** Five separate clauses of the constitution — the standard-library principle, the conservative-analysis principle, the secrets/data-locality principle, the AI-provider and networking constraints, and the dry-run quality gate — are written around an AI provider. Until they are amended, the governing document and the product contradict each other, and every future feature's Constitution Check inherits that contradiction.
+
+The cost of inaction is a tool that is harder to adopt than it needs to be, describes itself inaccurately, discloses more inventory data than its own capability requires, and carries a maintenance tax on every document and test for a mode nobody should choose.
+
+## 3. Business Objectives & Goals
+
+- **G1** — Make the deterministic, Cisco-sourced check the tool's single behaviour, reachable by typing `caia` with nothing else.
+- **G2** — Remove the AI capability completely, so no run can contact an AI service, require an AI credential, or produce an AI-derived verdict.
+- **G3** — Reduce what a new user must obtain and configure before their first successful run to one credential set: Cisco PSIRT API access.
+- **G4** — Shrink the data the tool discloses externally to only what the Cisco check needs.
+- **G5** — Make every user-facing description — command, documentation, landing page, package metadata, product name — an accurate account of a deterministic tool.
+- **G6** — Bring the governing constitution back into agreement with the product, with no residual reference to an AI provider or an AI-derived determination.
+- **G7** — Preserve the inventory-driven check's behaviour exactly: same verdicts, same report, same reasons, same exit statuses. Only the way it is invoked changes.
+- **G8** — Leave no residue: no dead code, tests, sample files, automated checks, or documentation belonging to the retired mode.
+
+## 4. Stakeholders & Users
+
+| Stakeholder / user | Role in this product | What they need from it |
+| ------------------ | -------------------- | ---------------------- |
+| Security / network analyst | Primary user | One command that answers "what is my exposure?"; no AI key to obtain; no choice to make between two modes |
+| Existing user of the advisory-driven mode | Affected user | To be told plainly that the mode is gone and what replaces it, rather than meeting a confusing error |
+| Firewall / patching engineer | Downstream actor | The same HTML worklist they use today, unchanged by this removal |
+| Team lead / security manager | Reviewer, reporting | Confidence that every verdict now comes from Cisco's published data, and an accurate description of the tool to pass on |
+| IT / security review | Reviewer | Evidence that the external AI dependency and its data disclosure are gone, and that the credential surface has shrunk |
+| Maintainers | Owners | A single-mode codebase, documentation set, and constitution with no dual-mode caveats to keep in sync |
+| New user | Onboarding | A prerequisites list, install path, and first run with no AI service in it |
+
+## 5. Scope
+
+### 5.1 In Scope
+
+- Making the inventory-driven Cisco API check the behaviour of `caia` invoked with no mode flag.
+- Removing the advisory-driven analysis: the advisory/ERP URL flow, the AI assessment of advisory content, the downloading and retention of advisory CSAF files, and the Excel report it produced.
+- Removing every command-line option that existed only to serve that mode — the mode selector, the advisory URL, and the two options controlling retention of downloaded CSAF files — and refusing any removed option with a clear, actionable message rather than a generic argument error.
+- Removing the dry run entirely, including its preview of the checks that would be made, and removing the constitutional quality gate that requires user-facing changes to be exercisable through it. Every invocation therefore requires Cisco credentials and contacts Cisco.
+- Correcting the description of every option that survives, so none of them describes the retired mode's behaviour.
+- Removing the AI service credential and its related settings from configuration entirely: no prompt, no read, no honouring of an existing stored or environment value.
+- Ensuring `caia --config` collects only the Cisco client identifier and client secret, preserving its existing verification behaviour.
+- Renaming the product to **Cisco Advisory Impact Analyzer**, keeping the `caia` command, the `caia-cli` distribution name, and the repository name unchanged.
+- Correcting every user-facing description — the startup banner's product name and slogan, the command help and its option descriptions, the interactive configuration screen, the message shown after uninstalling, the comments written into the per-user configuration file, `README.md`, `docs/index.html`, package metadata, and the sample environment file — to describe a deterministic, Cisco-data-driven tool with no AI claim.
+- Adopting one canonical tagline and one canonical command description (given verbatim in BR-17) and using each one everywhere it appears, so the four copies of today's tagline cannot drift apart again.
+- Redefining what counts as an unattended or flag-driven run wherever that was previously inferred from the presence of the advisory URL.
+- Amending the constitution so that no principle, constraint, or quality gate refers to an AI provider, an AI-derived determination, the AI endpoint, or the Excel report.
+- Removing the retired mode's tests, sample values, and clean-room install-test steps, and confirming the surviving suite passes.
+- Bumping the product version to 4.0.0 and cutting the release through the project's existing release process.
+
+### 5.2 Out of Scope
+
+- Any change to how the inventory-driven check reaches its verdicts — matching, catalogues, quotas, verdict rules, reasons, or report content. That behaviour is fixed by BRD-003 and must survive untouched.
+- Any new capability, output format, or flag. This is a removal, not a redesign.
+- Re-introducing AI for the judgement Cisco's data cannot make (whether a vulnerable feature is enabled). Explicitly not replaced by anything.
+- Migration tooling for users of the retired mode. User communication is handled directly by the product owner; the product's only obligation is a clear message on a retired flag.
+- Renaming the `caia` command, the `caia-cli` distribution, or the GitHub repository. Only the product's descriptive name changes.
+- Rewriting historical specs and BRDs (`specs/001`–`006`, `brds/001`–`003`). They are a record of decisions made at the time and stay as written.
+- Removing the ability to read a working-folder `.env` for automation; only its AI variables go away.
+- Changing the inventory format, its discovery, or the `output/` folder conventions.
+
+## 6. User Journeys *(feeds the spec's prioritized user stories)*
+
+### Journey 1 — One command, one answer (Priority: P1)
+
+- **Actor:** Security / network analyst with their inventory in the working folder and Cisco credentials configured.
+- **Trigger:** They run `caia`.
+- **Outcome / value:** The fleet-wide impact report, with no mode to choose, no URL to supply, and no AI key to have obtained first.
+- **Flow:**
+ 1. The analyst runs `caia` in the folder holding their inventory.
+ 2. The tool loads the inventory and reports how many devices it will assess.
+ 3. It asks Cisco which advisories apply to each distinct family / platform / release combination.
+ 4. It assigns every device Impacted, Not impacted, or Needs review.
+ 5. It writes a timestamped HTML report to the output folder and prints its location.
+- **Acceptance:**
+ - **Given** a valid inventory and configured Cisco credentials, **When** the analyst runs `caia` with no other flags, **Then** the inventory-driven check runs and a new timestamped HTML report is written and its path reported.
+ - **Given** the same inventory and credentials, **When** the run completes, **Then** its verdicts, reasons, report content, and exit status are identical to what `caia --api` produced before this change.
+ - **Given** any run, **When** it completes, **Then** no Excel report is produced, no advisory URL was requested or accepted, and no advisory CSAF file or temporary working folder was created.
+ - **Given** a run, **When** its network activity is observed, **Then** it contacts only Cisco's advisory and token services and — unless suppressed — GitHub for the version check, and no AI service.
+ - **Given** any option the product retains is supplied, **When** the tool runs, **Then** it behaves exactly as it does today for the inventory-driven check, and its description in the help matches what it actually does.
+ - **Given** an interactive run on a version that is out of date, **When** the tool starts, **Then** the update offer behaves per a stated rule that no longer depends on an advisory URL having been supplied.
+
+### Journey 2 — Set up with one credential set (Priority: P1)
+
+- **Actor:** New user, or an existing user reconfiguring.
+- **Trigger:** They run `caia --config`.
+- **Outcome / value:** Setup asks only for the Cisco client identifier and secret. Nothing prompts for an AI key, and nothing about a previously-stored AI key affects their runs.
+- **Flow:**
+ 1. The user runs `caia --config`.
+ 2. The tool prompts for the Cisco client identifier (visible) and the Cisco client secret (not echoed, with the existing capture confirmation).
+ 3. It verifies the credentials with Cisco, distinguishing a rejection from an unreachable service exactly as it does today.
+ 4. It saves the settings it still manages to the per-user configuration and reports where.
+- **Acceptance:**
+ - **Given** a fresh machine, **When** the user runs `caia --config`, **Then** they are prompted only for the Cisco client identifier and client secret, and never for an AI key, model, or endpoint.
+ - **Given** a per-user configuration containing AI settings from an earlier version, **When** any command runs, **Then** those values are ignored and change nothing about the run.
+ - **Given** that same configuration, **When** `caia --config` saves, **Then** the written file contains only the settings the product still manages, with its owner-only permissions and hand-editable format preserved.
+ - **Given** an AI-related environment variable is set, **When** any command runs, **Then** it has no effect and no message references it.
+ - **Given** Cisco rejects the credentials, or cannot be reached, **When** `caia --config` completes, **Then** it behaves exactly as it does today for each case.
+ - **Given** a user who has never configured an AI key, **When** they run `caia`, **Then** the run proceeds on Cisco credentials alone and never refuses for a missing AI key.
+ - **Given** the per-user configuration file is opened by hand, **When** its header comments are read, **Then** they document only the settings and environment overrides the product still honours, under the product's current name.
+ - **Given** the user uninstalls the product, **When** it reports which saved settings were left behind, **Then** it names only credentials the product actually stores.
+ - **Given** a non-interactive session, **When** the product is uninstalled with the unattended-confirmation option, **Then** it completes without a prompt exactly as it does today, and the option's description names the uninstall confirmation as the prompt it skips.
+
+### Journey 3 — A retired option says so (Priority: P2)
+
+- **Actor:** An existing user, or a script, still passing an option that belonged to the retired mode.
+- **Trigger:** They run the tool with the mode selector, an advisory URL, or one of the CSAF-retention options.
+- **Outcome / value:** An immediate, self-explanatory refusal — never a silent fall-through to a different behaviour, and never a bare "unrecognized argument" that leaves them guessing.
+- **Flow:**
+ 1. The user runs the tool with a removed option.
+ 2. The tool refuses before doing any work, naming the option as removed.
+ 3. It states what the tool now does instead — `caia` checks the whole inventory against Cisco's published data.
+ 4. It exits non-zero.
+- **Acceptance:**
+ - **Given** the former mode selector is supplied, **When** the tool runs, **Then** it exits non-zero with a message naming the option as removed and stating that the product now performs that check by default, and no analysis runs.
+ - **Given** the former advisory-URL option is supplied, **When** the tool runs, **Then** it exits non-zero with a message naming the option as removed and stating that the advisory-driven analysis no longer exists, and nothing is written and no advisory is fetched.
+ - **Given** either CSAF-retention option is supplied, **When** the tool runs, **Then** it exits non-zero with a message naming the option as removed and stating that the product no longer downloads advisory files.
+ - **Given** the dry-run option is supplied, **When** the tool runs, **Then** it exits non-zero with a message naming the option as removed, and no preview of the checks that would be made is produced.
+ - **Given** any removed option is supplied, **When** the tool refuses, **Then** the message is distinguishable from a generic unknown-argument error and tells the user what to run instead.
+ - **Given** the help is displayed, **When** the options are listed, **Then** no removed option appears, and no listed option's description refers to the retired mode.
+
+### Journey 4 — The tool describes itself accurately (Priority: P2)
+
+- **Actor:** A prospective or new user reading the landing page or README before installing.
+- **Trigger:** They open `docs/index.html` or `README.md`, or run `caia --help`.
+- **Outcome / value:** They learn what the tool actually is — a deterministic checker that reports Cisco's own published verdicts — under a name that does not imply AI, and a prerequisites list containing no AI service.
+- **Flow:**
+ 1. The reader sees the product name and one-line description.
+ 2. They read the prerequisites and obtain Cisco PSIRT API credentials.
+ 3. They install, run `caia --config`, then `caia`.
+ 4. Nothing they read mentions an AI provider, an AI key, a second mode, or an Excel report.
+- **Acceptance:**
+ - **Given** any user-facing text — product name, command help, README, landing page, package metadata, sample environment file — **When** it is read, **Then** it contains no claim of AI-driven or agentic analysis and no reference to an AI provider.
+ - **Given** the prerequisites, **When** a new user follows them, **Then** the only credential they are told to obtain is Cisco PSIRT API access.
+ - **Given** the README and the landing page, **When** they are compared, **Then** their install, configure, run, and update instructions agree with each other and with the code.
+ - **Given** the product name, **When** it appears anywhere, **Then** it is the agreed non-AI name and the `caia` command and `caia-cli` distribution are unchanged.
+ - **Given** a returning user reads the documentation, **When** they look for the advisory-URL mode, **Then** they find a plain statement that it was removed and what replaces it.
+
+### Journey 5 — Governance matches the product (Priority: P2)
+
+- **Actor:** Maintainer or reviewer planning the next feature.
+- **Trigger:** A feature enters `/speckit-plan` and its Constitution Check runs.
+- **Outcome / value:** The constitution describes the tool that exists, so the check is meaningful rather than something to explain away.
+- **Flow:**
+ 1. The constitution is amended to remove every AI-provider assumption.
+ 2. Its Sync Impact Report records what changed and why, and the version is bumped as a MAJOR amendment.
+ 3. The next feature's Constitution Check runs against rules that hold.
+- **Acceptance:**
+ - **Given** the amended constitution, **When** it is searched, **Then** no principle, constraint, or gate references an AI provider, an AI-derived determination, the AI endpoint, or the Excel report.
+ - **Given** the conservative-analysis principle, **When** it is read after amendment, **Then** it still forbids presenting an undecidable device as safe and still requires positive confirmation from Cisco data before "not impacted", with a single undecided label.
+ - **Given** the recorded network destinations, **When** they are read, **Then** they list only the destinations the tool still contacts.
+ - **Given** the amendment, **When** it is committed, **Then** it carries an updated Sync Impact Report and a MAJOR version bump, and any dependent templates are confirmed aligned.
+
+### Journey 6 — Nothing left behind (Priority: P3)
+
+- **Actor:** Maintainer reviewing the change, and CI.
+- **Trigger:** The pull request is opened.
+- **Outcome / value:** The removal is complete, not partial — no dormant code that could be re-enabled by accident, no tests exercising a capability that no longer exists, no dependency retained for a reason that has gone.
+- **Flow:**
+ 1. Every component belonging solely to the retired mode is deleted.
+ 2. Tests covering only the retired mode are removed; tests covering shared behaviour are retained and still pass.
+ 3. Sample files and the clean-room install test exercise only the surviving flows.
+ 4. Each remaining runtime dependency is confirmed still necessary.
+- **Acceptance:**
+ - **Given** the merged change, **When** the codebase is searched for the AI provider's name, **Then** it appears nowhere outside the historical `specs/` and `brds/` records.
+ - **Given** the test suite, **When** it runs, **Then** it passes with no test referencing the retired mode.
+ - **Given** the clean-room install test, **When** it runs end to end, **Then** every step it performs applies to the surviving tool.
+ - **Given** the declared runtime dependencies, **When** they are reviewed, **Then** each is still required by surviving behaviour and any that is not has been removed.
+ - **Given** the released version, **When** it is inspected, **Then** it is 4.0.0 and the tag, the committed version, and the version the installed tool reports agree.
+
+## 7. Business Requirements
+
+| ID | Requirement | Priority |
+| ----- | ------------------------------------ | -------- |
+| BR-01 | Invoked with no mode flag, the product MUST perform the inventory-driven check against Cisco's published advisory data and write the HTML impact report — the behaviour previously reached only via `--api`. | P1 |
+| BR-02 | The product MUST NOT offer any advisory-URL or Event-Response-driven analysis; that capability is removed, not hidden or deprecated. It MUST NOT download, retain, or create temporary storage for advisory CSAF files. | P1 |
+| BR-03 | The product MUST NOT contact any AI service, and MUST NOT require, prompt for, read, or honour an AI service credential from any source — per-user configuration, environment variable, or working-folder `.env`. | P1 |
+| BR-04 | The product MUST NOT produce a spreadsheet report; the single output of a run is the timestamped, self-contained HTML impact report. | P1 |
+| BR-05 | The verdicts, reasons, report content, progress output, and exit statuses of the inventory-driven check MUST be unchanged by this decommission; only the way it is invoked changes. | P1 |
+| BR-06 | Every option belonging to the retired mode MUST be removed and MUST NOT appear in the product's help: the mode selector (`--api`), the advisory URL (`--url`), the two CSAF-retention options (`--keep-temp`, `--no-keep-temp`), and the dry run (`--dry-run`). | P1 |
+| BR-07 | The dry-run capability MUST be removed outright rather than restated for the surviving check: the product MUST NOT offer any way to preview the checks it would make without performing them. Every invocation consequently requires Cisco credentials and contacts Cisco, and the product MUST continue to refuse clearly and before any request when those credentials are absent. | P1 |
+| BR-08 | Supplying a removed option MUST cause the product to refuse before performing any work, exit non-zero, name the option as removed, and state what the product does instead — distinguishably from a generic unknown-argument error. | P1 |
+| BR-09 | Every option the product retains — including those selecting the inventory, its sheet and the output folder, and those covering unattended confirmation, configuration, version, update, uninstall, and update-check suppression — MUST keep its current behaviour, and every retained option's description MUST state what that option actually does under the surviving check, with no reference to an AI call, an advisory URL, or downloaded advisory files. | P1 |
+| BR-10 | The unattended-confirmation option (`--yes`, `-y`) MUST be retained with its behaviour intact, so uninstalling without a prompt in a non-interactive session remains possible as defined by BRD-002. Its description MUST be narrowed to state that it skips the uninstall confirmation, which is the only prompt it governs once the retired mode's confirmation is gone. | P1 |
+| BR-11 | Where the product previously inferred that a run was flag-driven or unattended from the presence of an advisory URL, it MUST adopt a stated replacement rule, and that rule MUST be documented in the option's description where it is user-visible. | P2 |
+| BR-12 | The interactive configuration command MUST prompt only for the Cisco client identifier and the Cisco client secret, and MUST NOT prompt for an AI key, model, or endpoint. | P1 |
+| BR-13 | The interactive configuration command MUST preserve its existing behaviour for Cisco credentials: visible identifier prompt, non-echoing secret prompt with capture confirmation, skippable prompts, retention of an existing value on empty input, verification with Cisco before saving, and the distinction between a rejection and an unreachable service. | P1 |
+| BR-14 | When configuration is saved, the resulting per-user configuration file MUST contain only settings the product still manages, so obsolete AI settings do not persist; the file MUST retain its owner-only protection and remain hand-editable, and encountering obsolete values MUST NOT produce an error. | P2 |
+| BR-15 | The product MUST be presented under a name that does not imply AI or agentic behaviour, while the `caia` command, the `caia-cli` distribution name, and the repository name remain unchanged. | P1 |
+| BR-16 | Every user-facing description — product name, command help, README, landing page, package metadata, sample environment file, and in-product text — MUST describe the product as producing deterministic verdicts from Cisco's published advisory data, and MUST contain no claim of AI-driven or agentic analysis and no reference to an AI provider. | P1 |
+| BR-17 | The product's tagline and its one-line command description MUST be the agreed copy below, and each MUST be used verbatim wherever it appears so the two strings cannot drift apart again. Tagline: **Fleet-wide firewall impact analysis, straight from Cisco** — the single canonical string for the startup banner, the package metadata, the README tagline, and the landing page, carrying the ` · TELUS Digital` attribution on the surfaces that already include it. One-line description: **Checks every firewall in your inventory against Cisco's published advisory data, and writes an HTML impact report.** Neither string may characterise the result as a binary affected/unaffected answer, since the product returns three verdicts. | P1 |
+| BR-18 | Documentation MUST list Cisco PSIRT API credentials as the only credential a user obtains, and MUST NOT list an AI service key among the prerequisites, the configuration steps, or the troubleshooting guidance. | P1 |
+| BR-19 | The README and the landing page MUST remain consistent with each other and with the product — one mode, one report, one credential set, and the current set of network destinations — within the same change. | P1 |
+| BR-20 | Documentation MUST state plainly that the advisory-URL mode has been removed and what a returning user should run instead. | P2 |
+| BR-21 | The project constitution MUST be amended so that no principle, constraint, or quality gate references an AI provider, an AI-derived determination, the AI endpoint, or the spreadsheet report. The quality gate requiring user-facing changes to be exercisable through a dry run MUST be deleted rather than reworded, since the capability it depends on is removed, and any illustrative list of command-line options in the principles MUST cite only options that still exist. | P1 |
+| BR-22 | The amended constitution MUST retain the conservative-analysis principle in substance: an undecidable device MUST never be presented as safe, "not impacted" MUST rest on positive confirmation from Cisco's published data, every determination MUST be traceable to that data, and a single unambiguous undecided label MUST be used. | P1 |
+| BR-23 | The amended constitution MUST record only the network destinations the product still contacts, and MUST retain the rule that the product sends Cisco only the derived product family, hardware platform, and software release — never a firewall name or other inventory identifier. | P1 |
+| BR-24 | The constitutional amendment MUST carry an updated Sync Impact Report, MUST bump the constitution's own version as a MAJOR amendment, and MUST confirm the dependent Spec Kit templates remain aligned. | P1 |
+| BR-25 | No component, sample file, automated test, or clean-room install check may retain a dependency on the removed capability, and the surviving test suite MUST pass. | P1 |
+| BR-26 | A completed run MUST demonstrably contact only Cisco's advisory and token services and — unless suppressed — the version-check service, with no request to any AI endpoint. | P1 |
+| BR-27 | The clean-room install test MUST exercise only surviving flows, including a run invoked with no mode flag and a refusal when credentials are absent. | P2 |
+| BR-28 | The sample environment file MUST document only the variables the product still honours. | P2 |
+| BR-29 | Each declared runtime dependency MUST be confirmed still required by surviving behaviour, and any dependency retained solely for the removed capability MUST be removed. | P2 |
+| BR-30 | The product version MUST be raised to 4.0.0, reflecting the removal of documented options and a documented mode, and released through the project's existing tag-and-release process. | P1 |
+| BR-31 | Historical specification and BRD records MUST be left as written; correcting the product's description MUST NOT involve rewriting the record of past decisions. | P3 |
+
+## 8. Success Metrics & Measurable Outcomes
+
+- **SC-01** — A user with Cisco credentials configured obtains a complete impact report by typing one command with no flags.
+- **SC-02** — A new user reaches their first successful run having obtained exactly one credential set.
+- **SC-03** — 100% of runs produce verdicts, reasons, and report content identical to what the previous `--api` mode produced for the same inventory and credentials.
+- **SC-04** — 0 requests to any AI endpoint across any run, in any mode of invocation, including with AI environment variables set.
+- **SC-05** — 0 occurrences of the AI provider's name in the product, its documentation, its samples, its tests, and its governing constitution, outside the historical `specs/` and `brds/` records.
+- **SC-06** — 100% of invocations using a removed option are refused before any work is done, with a message that names the option and states what to run instead.
+- **SC-07** — A user whose stored configuration or environment still carries AI settings sees no change in behaviour and no error attributable to them.
+- **SC-08** — 0 references to an AI service key remain in the prerequisites, configuration steps, or troubleshooting guidance of either the README or the landing page, and their instructions agree with each other and with the product.
+- **SC-09** — The constitution's Constitution Check can be answered affirmatively for the product as shipped, with no gate requiring an exception or explanation.
+- **SC-10** — The test suite passes with no test exercising the removed capability, and the clean-room install test completes end to end.
+- **SC-11** — The released version is 4.0.0, and the committed version, the git tag, and the version the installed tool reports all agree.
+- **SC-12** — The volume of user-facing documentation needed to explain how to run the tool is measurably smaller, with no mode-comparison decision left for the reader to make.
+
+## 9. Assumptions
+
+- Users of the retired advisory-driven mode are known to the product owner and will be told directly; the product's only obligation to them is a clear refusal message and an accurate document.
+- Nobody depends on the spreadsheet report as a machine-readable artifact for downstream tooling; the HTML report is the accepted output.
+- The inventory-driven check as delivered by BRD-003 is fit for purpose as the tool's sole behaviour, and its known coverage limits (per-family advisory publication cut-offs; verdicts that do not account for whether a vulnerable feature is enabled) remain acceptable and disclosed.
+- Reading the inventory spreadsheet remains necessary, so the spreadsheet-reading dependency stays even though the spreadsheet-writing capability goes.
+- Working-folder `.env` support stays for automation convenience; only its AI variables disappear.
+- Obsolete settings left in an existing per-user configuration file are harmless once nothing reads them, so no migration step or cleanup command is required — the next save simply stops carrying them forward.
+- Retaining `caia`, `caia-cli`, and the repository name means no user's install, PATH, scripts, or bookmarks break on the rename; only the descriptive product name changes.
+- The GitHub repository description and any external listing of the product are updated by the product owner outside this change.
+- Removing a documented mode and two documented options is a breaking change under semantic versioning, making 4.0.0 the correct product version.
+- Removing an AI-provider assumption from a principle, and removing a named constraint, is a backward-incompatible redefinition under the constitution's own amendment rules, making a MAJOR constitutional bump correct.
+
+## 10. Constraints
+
+- The inventory-driven check's behaviour is fixed: this change may not alter its verdicts, reasons, report, or exit statuses. Any observable difference is a defect, not an improvement.
+- The conservative-analysis posture is non-negotiable and survives the removal intact: no undecidable device may ever be presented as safe.
+- Credential handling must keep its current posture — per-user storage outside any working folder, owner-only where the OS supports it, never printed, logged, or written into a report or filename.
+- The product must behave identically on macOS, Windows, and Linux.
+- The minimal-dependency posture applies in reverse here: a dependency that survives must still be justified by surviving behaviour.
+- Documentation consistency is a merge gate: the README and the landing page must be corrected in the same change as the code.
+- The constitutional amendment must be in place before the implementation's Constitution Check can pass, so it precedes or accompanies planning rather than following implementation.
+- The change must reach `main` through a pull request from its own spec branch, per the project's branching rules.
+- Reports written by earlier versions must remain readable and must not be touched.
+
+## 11. Dependencies
+
+- **Input** — The constitutional amendment (`/speckit-constitution`), which must land before `/speckit-plan`'s Constitution Check can be answered honestly. This is the one ordering dependency in the change.
+- **Input** — BRD-003 and spec 006, which define the behaviour being promoted to the product's only behaviour; this BRD inherits their requirements rather than restating them.
+- **Input** — Cisco's PSIRT advisory service and its authentication service, now the product's only source of verdicts and its only analysis-time external dependency.
+- **Input** — The user's Cisco API credentials, captured through the interactive configuration command.
+- **Output** — The corrected `README.md` and `docs/index.html`, which the Documentation quality gate requires to stay mutually consistent.
+- **Output** — A 4.0.0 release cut through the existing tag-and-release process.
+- **Related** — The version-check and self-update capability (BRD-001) and the self-uninstall capability (BRD-002), which are untouched but share the command surface and must keep working after the parser changes.
+- **Related** — The clean-room install test, which currently exercises the retired mode and must be reduced to surviving flows.
+- **External** — The product owner's direct communication to existing users of the retired mode, and any update to the repository's public description.
+
+## 12. Risks & Mitigations
+
+| Risk | Impact | Likelihood | Mitigation |
+| ------ | ------ | ---------- | ---------- |
+| Removing the shared parser and configuration plumbing changes the surviving check's behaviour without anyone noticing | H | M | Treat behavioural equivalence as an explicit acceptance criterion (BR-05, SC-03); keep the existing inventory-driven test suite as the regression guard and require it to pass unmodified in substance |
+| An existing script runs `caia --api` and, on the new version, silently does something different or fails opaquely | M | H | Refuse removed options explicitly with a named, actionable message rather than relying on a generic argument error (BR-08, SC-06) |
+| A partial removal leaves dormant AI code or configuration that can be re-entered by an unusual invocation | H | L | Delete rather than disable; assert absence by search across product, docs, samples, and tests (BR-25, SC-05) |
+| Stored or environment AI settings on existing machines cause an error, a stale prompt, or unexpected behaviour after upgrade | M | M | Ignore obsolete values entirely and stop carrying them forward on the next save; never error on their presence (BR-03, BR-14, SC-07) |
+| The constitution is amended too late, so the implementation is planned and reviewed against rules the product no longer satisfies | M | M | Sequence the amendment as a prerequisite of planning and record it as the change's only ordering dependency (BR-21, Section 11) |
+| The conservative-analysis principle is weakened while editing the AI wording out of it | H | L | State its substance as a requirement in its own right so the amendment is checked against it, not just for what it removes (BR-22) |
+| Documentation is corrected in one place but not the other, leaving contradictory instructions | M | M | Existing Documentation gate applies: README and landing page corrected in the same change and checked against each other (BR-19) |
+| The rename ripples further than intended and breaks an install path, a script, or a link | M | L | Change only the descriptive product name; explicitly hold the command, distribution name, and repository name fixed (BR-15) |
+| A user upgrades expecting the advisory-URL mode for an advisory older than Cisco's data cut-off and finds no substitute | M | M | Accepted and deliberate: state the removal and Cisco's coverage limits plainly so the gap is visible rather than discovered mid-incident (BR-20, and the report's existing coverage disclosure) |
+| A dependency is dropped as "AI-only" when surviving behaviour still needs it, breaking installs | M | L | Require each dependency to be re-justified against surviving behaviour rather than removed by association (BR-29) |
+| The clean-room install test is left asserting the retired mode and fails or, worse, is quietly skipped | L | M | Reduce it to surviving flows as part of the same change and run it end to end (BR-27, SC-10) |
+| Removing the dry run leaves no way to exercise the product end to end without Cisco credentials, narrowing what the clean-room install test and any offline check can prove | M | H | Accepted as a deliberate consequence of the removal (BR-07): the credential-refusal path, inventory loading and its errors, and the unit test suite remain the credential-free coverage, and the install test is reduced to those |
+| A user validating a new or corrected inventory has no preview, so they spend Cisco request quota on a run made only to test the file | L | M | Accepted: a malformed or undiscoverable inventory still fails before any request is made, and repeated checks of a valid inventory collapse to the same small set of requests |
+
+## 13. Open Questions
+
+- What replaces "an advisory URL was supplied" as the test for a flag-driven or unattended run, which currently governs whether the start-of-run update offer appears? (Leaning: rely on the interactive-terminal check alone.)
+- Should the refusal for a removed option use the product's existing argument-error exit status, or a distinct status so automation can tell "you used a retired option" from "you mistyped an option"?
+- Should the obsolete settings in an existing per-user configuration file be dropped only when the user next saves configuration, or actively pruned on first run of the new version? (Leaning: only on save — the file is the user's, and a silent rewrite on an ordinary run is surprising.)
+- Where should the "this mode was removed, run `caia` instead" note live so a returning user actually meets it — the README, the release notes, or both?
+- Now that only one mode remains, is working-folder `.env` support still earning its dependency, or should automation rely on environment variables alone?
+- The report's own heading is already "Cisco Advisory Impact Report" — no AI claim and no "Agent" — so it needs no change. Confirm that it stays as written rather than tracking the product's new name.
+
+_Resolved during BRD review:_ the product is renamed **Cisco Advisory Impact Analyzer**, retaining `caia`, `caia-cli`, and the repository name (BR-15); the mode selector, the advisory URL, both CSAF-retention options, and the dry run are **removed outright** rather than kept as deprecated aliases (BR-06); the dry run is removed as a **capability**, not merely renamed, taking the constitution's dry-run quality gate with it and accepting that every invocation now needs credentials and contacts Cisco (BR-07, BR-21); the unattended-confirmation option is **kept** — it belongs to BRD-002's uninstall, not to the retired mode — with its description narrowed to the uninstall confirmation it actually governs (BR-10); AI settings are **no longer read from any source** and configuration **no longer prompts for them**, with obsolete values simply not carried forward on the next save (BR-03, BR-12, BR-14); the **constitution is amended in scope of this change** so that no rule references an AI provider (BR-21); the product version moves to **4.0.0** (BR-30); and the tagline and one-line description are settled as the agreed copy in BR-17, deliberately framed inventory-first and around Cisco as the source of truth, since the product's framing flipped from "given this advisory, which firewalls?" to "given my fleet, what does Cisco list against it?" (BR-17).
+
+## 14. Glossary
+
+| Term | Definition |
+| ---- | ---------- |
+| `caia` | The command the product installs on the user's PATH; unchanged by this BRD. After this change, running it with no mode flag performs the inventory-driven check. |
+| `caia-cli` | The distribution name the product is installed from; unchanged by this BRD. |
+| Cisco Advisory Impact Analyzer | The product's name after this change, replacing "Cisco Advisory Impact Agent" so the name no longer implies AI or agentic behaviour. |
+| Advisory-driven mode | The behaviour being retired: given an advisory or Event Response link, an AI model read the advisory and inferred which inventory firewalls were affected, producing a spreadsheet report. |
+| Inventory-driven check | The behaviour delivered by BRD-003 and, after this change, the product's only behaviour: every inventory device is checked against Cisco's published advisory data and given a verdict. |
+| AI service credential | The per-user key the retired mode required to reach its AI provider. Removed entirely: no longer prompted for, stored, read, or honoured. |
+| Per-user configuration | The stored credentials and settings kept in an OS-appropriate per-user location outside any working folder, so a run from any folder finds them. |
+| Impacted / Not impacted / Needs review | The three verdicts the surviving check assigns, unchanged by this BRD. "Needs review" is the single undecided label after the retired mode's "Indeterminate" disappears with it. |
+| Impact report | The timestamped, self-contained HTML file the surviving check writes into the output folder — after this change, the product's only report. |
+| Spreadsheet report | The timestamped Excel file the retired mode produced. Removed. |
+| CSAF file | The machine-readable form of a Cisco advisory that the retired mode downloaded, saved to a temporary folder, and fed to the AI model. The surviving check never downloads one, so the options that controlled retaining or deleting them go with the mode. |
+| Dry run | An invocation that resolved every inventory device to the question that would be asked of Cisco and reported it, without contacting Cisco and without writing a report. Removed by this BRD along with the constitutional quality gate built on it; after this change every invocation requires credentials and contacts Cisco. |
+| Startup banner | The splash printed on an interactive terminal, carrying the wordmark, the product name, and the slogan. Both the name and the slogan currently assert agentic AI analysis. |
+| Flag-driven run | A run whose inputs are all supplied as options rather than prompted for. The product currently infers this from the presence of an advisory URL, which disappears with the retired mode. |
+| Constitution | The project's governing document, whose principles and gates every feature is checked against during planning. Amended by this change. |
+| Sync Impact Report | The record at the head of the constitution describing what a given amendment changed, why, and how the version was bumped. |
+| Constitution Check | The gate in the planning workflow that verifies a proposed feature complies with the constitution's principles. |
+| Breaking change | A change that removes or alters documented behaviour users may depend on — here, the removal of a mode and two options — requiring a major version increment. |
diff --git a/caia/__init__.py b/caia/__init__.py
index 068bdc4..defd461 100644
--- a/caia/__init__.py
+++ b/caia/__init__.py
@@ -1,4 +1,4 @@
-"""Cisco Advisory Impact Agent — AI-driven Cisco firewall advisory impact analysis.
+"""Cisco Advisory Impact Analyzer — fleet-wide firewall impact analysis, straight from Cisco.
Distributed as a uv tool; the on-PATH command is `caia`
(entry point: :func:`caia.cli.main`).
diff --git a/caia/analyzer.py b/caia/analyzer.py
index 9801ccb..bc0eac4 100644
--- a/caia/analyzer.py
+++ b/caia/analyzer.py
@@ -1,33 +1,26 @@
#!/usr/bin/env python3
"""
-Cisco Advisory Impact Agent (AI-driven)
-=======================================
-Given a Cisco Event Response (ERP) URL, find every referenced security advisory,
-download its CSAF, extract the "Vulnerable Products" / "Products Confirmed Not
-Vulnerable" sections plus the enumerated affected releases, and ask the FueliX AI
-which firewalls in your inventory are impacted. Writes a timestamped Excel report.
-
-This module holds the analysis run flow; the command-line entry point is
-`caia.cli`. Inputs are read from, and the report is written
-to, the current working folder (an `output/` subfolder), so the tool works from any
-folder once installed.
+Cisco Advisory Impact Analyzer
+==============================
+Check every firewall in the inventory against Cisco's published advisory data. Each
+device's product family, hardware platform and software release are put to Cisco's
+PSIRT advisory API — the same data behind Cisco's public Software Checker — and the
+answer is reported directly. Writes one timestamped, self-contained HTML report.
+
+This module holds the run flow; the command-line entry point is `caia.cli`. Inputs are
+read from, and the report is written to, the current working folder (an `output/`
+subfolder), so the tool works from any folder once installed.
"""
from __future__ import annotations
import datetime as _dt
-import shutil
import sys
-import tempfile
from pathlib import Path
-from urllib.parse import parse_qs, urlparse
-from caia import cisco, config, fuelix, html_report, matching, psirt
+from caia import config, html_report, matching, psirt
from caia import inventory as inv
from caia import ui
-from caia.report import write_report
-
-CISCO_HOST = "sec.cloudapps.cisco.com"
def die(msg, hint=None):
@@ -120,211 +113,18 @@ def _pick_inventory(folder, sheet):
f"'{sheet}' and the expected columns). Please choose another.")
-# --------------------------------------------------------------------------- #
-# URL validation / prompting
-# --------------------------------------------------------------------------- #
-def is_valid_cisco_url(url):
- try:
- u = urlparse(url.strip())
- except Exception: # noqa: BLE001
- return False
- if u.scheme not in ("http", "https") or u.netloc.lower() != CISCO_HOST:
- return False
- if "viewErp" in u.path:
- alert = parse_qs(u.query).get("alertId", [""])[0]
- return alert.upper().startswith("ERP-")
- return bool(cisco.SLUG_RE.search(u.path))
-
-
-def prompt_for_url():
- ui.plain()
- ui.system("Paste the Cisco Event Response (ERP) URL.")
- ui.plain(ui.dim("Example: https://sec.cloudapps.cisco.com/security/center/"
- "viewErp.x?alertId=ERP-75736"))
- for _ in range(5):
- url = ui.ask("URL")
- if not url:
- die("No URL provided. Exiting.")
- if is_valid_cisco_url(url):
- return url
- ui.warn(f"That doesn't look like a Cisco advisory/ERP URL on {CISCO_HOST}. "
- "Please try again.")
- die("Too many invalid attempts. Exiting.")
-
-
-# --------------------------------------------------------------------------- #
-# Per-advisory processing
-# --------------------------------------------------------------------------- #
-def download_and_extract(slug, temp_dir):
- """Fetch a CSAF, save it to temp_dir, and return the extracted advisory dict
- (or None if the CSAF could not be retrieved/parsed)."""
- csaf = cisco.fetch_json(cisco.csaf_url(slug))
- if not csaf:
- return None
- try:
- (temp_dir / f"{slug}.json").write_text(_dumps(csaf), encoding="utf-8")
- except Exception: # noqa: BLE001
- pass # saving the raw file is best-effort; extraction is what matters
- return cisco.extract_advisory(csaf, slug=slug)
-
-
-def _dumps(obj):
- import json
-
- return json.dumps(obj, indent=2, ensure_ascii=False)
-
-
-def assessment_text(result, combo_map):
- """Turn a fuelix result dict into the output cell text."""
- category = result["category"]
- if category == "affected":
- names = inv.expand_combo_ids(result["impacted_combo_ids"], combo_map)
- return "\n".join(names) if names else "Not Affected"
- if category == "not_affected":
- return "Not Affected"
- return "Indeterminate"
-
-
def _result_line(adv_id, label, color, stream=None):
line = (f"{ui.dim('-')} {ui.bold(adv_id)} {ui.dim('->')} {color(label)}")
print(line, file=stream or sys.stdout)
-def _assessment_label(assessment):
- """Short colored label + color function for a per-advisory assessment cell."""
- if assessment == "Indeterminate":
- return "Indeterminate", ui.yellow
- if assessment == "Not Affected":
- return "Not Affected", ui.gray
- names = assessment.splitlines()
- label = names[0] + (f" (+{len(names) - 1} more)" if len(names) > 1 else "")
- return label, ui.green
-
-
# --------------------------------------------------------------------------- #
# Run flow
# --------------------------------------------------------------------------- #
-def run(args):
- """Execute the analysis flow for the parsed CLI args. Returns a process exit code.
-
- A supplied flag skips its interactive prompt; providing the URL makes the run fully
- flag-driven, which also skips the confirmation prompt (FR-025).
- """
- config.load_local_env()
- api_key = config.resolve(config.API_KEY)
- model = config.resolve(config.MODEL)
- base_url = config.resolve(config.BASE_URL)
-
- output_dir = Path(args.output_dir) if args.output_dir else default_output_dir()
- interactive = sys.stdin.isatty()
- fully_flag_driven = bool(args.url)
-
- # 1. API key check (skipped for dry-run so the pipeline can be tested offline).
- if not args.dry_run and not api_key:
- die("No FueliX API key is configured.",
- hint="Run 'caia --config' to set your FueliX API key.")
-
- # 2. Inventory (discover in the working folder or let the user pick).
- inv_path = resolve_inventory(args.inventory, interactive, sheet=args.sheet)
- try:
- inventory = inv.load_inventory(inv_path, args.sheet)
- except inv.InventoryError as e:
- die(str(e))
- if not inventory:
- die(f"Inventory {inv_path} has no firewall rows.")
- combos, combo_map = inv.build_combos(inventory)
- ui.ok(f"Loaded {ui.bold(str(len(inventory)))} firewalls "
- f"({len(combos)} distinct combos) from {ui.bold(inv_path.name)}.")
-
- # 3. URL (supplied via --url or prompted).
- if args.url:
- url = args.url.strip()
- if not is_valid_cisco_url(url):
- die(f"Invalid Cisco URL: {url}")
- else:
- url = prompt_for_url()
-
- # 4. Confirmation (skipped in a fully flag-driven run).
- if not fully_flag_driven:
- ui.plain()
- ui.system(f"The advisory URL will be analyzed and the report saved to "
- f"{ui.bold(str(output_dir))} in this folder.")
- if not ui.confirm("Continue", default=False):
- ui.plain("No changes made.")
- return 0
-
- # 5. Discover advisories.
- ui.plain()
- ui.system("Discovering advisories ...")
- slugs = cisco.discover_advisories(url)
- if not slugs:
- die("No advisories found at that URL.")
- ui.ok(f"Found {ui.bold(str(len(slugs)))} advisory(ies).")
-
- # 6. Temp folder for CSAF downloads.
- temp_dir = Path(tempfile.mkdtemp(prefix="csaf_"))
- ui.info("Downloading CSAF files to: " + ui.dim(str(temp_dir)))
-
- if not args.dry_run:
- ui.system(f"Analyzing with FueliX model '{model}' ...")
- ui.plain()
-
- # 7. Process each advisory.
- results = []
- for slug in slugs:
- adv = download_and_extract(slug, temp_dir)
- if adv is None:
- _result_line(slug, "Indeterminate (CSAF unavailable)", ui.yellow)
- results.append((slug, "CSAF could not be retrieved.", "Indeterminate"))
- continue
-
- if args.dry_run:
- desc = (adv["vulnerable_products"] or adv["title"] or "")[:300]
- fams = ", ".join(sorted(adv["affected_versions"])) or "none"
- _result_line(adv["id"], f"DRY-RUN (families: {fams})", ui.cyan)
- results.append((adv["id"], desc, "DRY-RUN (no AI call)"))
- continue
-
- try:
- res = fuelix.assess_advisory(adv, combos, api_key, model=model,
- base_url=base_url)
- assessment = assessment_text(res, combo_map)
- desc = res["summary"] or (adv["vulnerable_products"] or adv["title"])
- label, color = _assessment_label(assessment)
- _result_line(adv["id"], label, color)
- results.append((adv["id"], desc, assessment))
- except Exception as e: # noqa: BLE001
- _result_line(adv["id"], f"ERROR: {e}", ui.red, stream=sys.stderr)
- results.append((adv["id"],
- adv["vulnerable_products"] or adv["title"],
- f"ERROR: {e}"))
-
- # 8. Write the report (creating output/ if needed; fail clearly if it cannot be created).
- try:
- ensure_dirs(output_dir)
- out = write_report(results, str(output_dir))
- except OSError as e:
- die(f"Could not write the report to {output_dir}: {e}",
- hint="Check that the current folder is writable, or pass --output-dir DIR.")
- ui.plain()
- ui.ok("Wrote " + ui.bold(out))
-
- # 9. Temp cleanup.
- if args.keep_temp:
- ui.plain(ui.dim(f"CSAF files kept at: {temp_dir}"))
- else:
- shutil.rmtree(temp_dir, ignore_errors=True)
- return 0
-
-
-# --------------------------------------------------------------------------- #
-# Inventory-driven run (`--api`)
-# --------------------------------------------------------------------------- #
def _resolve_cisco_credentials():
"""Return (client_id, client_secret) or exit with an actionable message.
- Refuses before any request is made, naming exactly what is missing (FR-048). The AI
- service key is deliberately not consulted — this mode never calls it (FR-005, FR-036).
+ Refuses before any request is made, naming exactly what is missing (spec 007 FR-007).
"""
client_id = config.resolve(config.CISCO_CLIENT_ID)
client_secret = config.resolve(config.CISCO_CLIENT_SECRET)
@@ -368,19 +168,19 @@ def _describe_check(check):
return f"{os_type}/{alias or '-'}/{release}"
-def run_api(args):
+def run(args):
"""Assess the whole inventory against Cisco's published advisory data.
- Returns a process exit code. Writes one self-contained HTML report per run.
+ Returns a process exit code. Writes one self-contained HTML report per run. Every run
+ needs credentials and contacts Cisco; there is no offline path (spec 007 FR-009).
"""
config.load_local_env()
output_dir = Path(args.output_dir) if args.output_dir else default_output_dir()
interactive = sys.stdin.isatty()
- # 1. Credentials (skipped for a dry run, which contacts nothing).
- if not args.dry_run:
- client_id, client_secret = _resolve_cisco_credentials()
+ # 1. Credentials.
+ client_id, client_secret = _resolve_cisco_credentials()
# 2. Inventory.
inv_path = resolve_inventory(args.inventory, interactive, sheet=args.sheet)
@@ -392,11 +192,7 @@ def run_api(args):
die(f"Inventory {inv_path} has no firewall rows.")
ui.ok(f"Loaded {ui.bold(str(len(inventory)))} firewalls from {ui.bold(inv_path.name)}.")
- # 3. Dry run: resolve every device to the question that would be asked, contact nothing.
- if args.dry_run:
- return _dry_run(inventory)
-
- # 4. Catalogues, then one question per distinct check.
+ # 3. Catalogues, then one question per distinct check.
client = psirt.Client(client_id, client_secret)
ui.plain()
ui.system("Fetching Cisco platform and release catalogues ...")
@@ -417,12 +213,12 @@ def run_api(args):
results, partial_cause = _run_checks(client, checks)
- # 5. Verdicts.
+ # 4. Verdicts.
devices = _assemble_devices(inventory, catalogues, checks, unresolved, results)
summary = _build_summary(inv_path, devices, partial_cause)
_print_verdict_tally(summary)
- # 6. Report.
+ # 5. Report.
try:
ensure_dirs(output_dir)
out = html_report.write_report(devices, summary, str(output_dir))
@@ -434,48 +230,6 @@ def run_api(args):
return 0
-def _dry_run(inventory):
- """Show what a real run would ask Cisco, without contacting Cisco.
-
- Uses the same resolution the real run uses, so devices that would never reach the API
- are listed as such rather than shown as questions that would be asked. Alias
- validation is skipped because that needs Cisco's live platform catalogue — so a model
- shown here as mappable could still turn out to be one Cisco has not aliased yet.
- """
- ui.plain()
- ui.system("Dry run - resolving each firewall to the check that would be made. "
- "Cisco is not contacted.")
-
- catalogues = matching.Catalogues() # empty: nothing to validate an alias against
- checks, unresolved = matching.build_checks(inventory, catalogues, validate_alias=False)
-
- ui.plain()
- ui.plain(ui.bold("Would ask Cisco:"))
- for check in sorted(checks, key=lambda c: (c[0], c[1] or "", c[2])):
- os_type, alias, release = check
- label = f"{os_type}/{alias or '(no platform)'}/{release}"
- count = len(checks[check])
- ui.plain(f" {label:<46} {ui.dim(str(count) + ' device(s)')}")
-
- if unresolved:
- ui.plain()
- ui.plain(ui.bold("Would not reach Cisco - Needs review:"))
- grouped = {}
- for device, reason in unresolved:
- grouped.setdefault(reason.kind, []).append(device["name"])
- for kind in sorted(grouped):
- names = grouped[kind]
- shown = ", ".join(names[:4]) + (" ..." if len(names) > 4 else "")
- ui.plain(f" {kind:<26} {len(names):>3} device(s) {ui.dim(shown)}")
-
- ui.plain()
- ui.ok(f"{len(inventory)} firewalls would produce {ui.bold(str(len(checks)))} checks"
- + (f", with {len(unresolved)} resolved locally as Needs review" if unresolved
- else "")
- + ". No report written.")
- return 0
-
-
def _run_checks(client, checks):
"""Ask Cisco once per check. Returns ({check: (outcome, advisories)}, partial_cause).
diff --git a/caia/cisco.py b/caia/cisco.py
deleted file mode 100644
index 6b60b6d..0000000
--- a/caia/cisco.py
+++ /dev/null
@@ -1,198 +0,0 @@
-"""
-Cisco data access: fetch an Event Response (ERP) page, discover the advisories it
-references, download each advisory's CSAF JSON, and extract the pieces we care about
-(the two product sections and the enumerated affected releases).
-
-All HTTP uses the standard library so the app has no networking dependency.
-"""
-
-from __future__ import annotations
-
-import html as _html
-import json
-import re
-import sys
-import urllib.error
-import urllib.request
-
-BASE = "https://sec.cloudapps.cisco.com"
-UA = {"User-Agent": "Mozilla/5.0 (compatible; AdvisoryImpactBot/0.1)"}
-
-# Product families we can reason about from the firewall inventory. FMC is included
-# so FMC-only advisories carry their versions into the AI context (the AI then marks
-# them Indeterminate because the inventory holds no FMC devices).
-MATCHABLE_FAMILIES = {"ASA", "FTD", "FMC"}
-
-# Slug of a Cisco security advisory, e.g. cisco-sa-onprem-fmc-authbypass-5JPp45V2
-SLUG_RE = re.compile(r"/CiscoSecurityAdvisory/(cisco-sa-[A-Za-z0-9\-]+)")
-
-
-# --------------------------------------------------------------------------- #
-# Fetch helpers
-# --------------------------------------------------------------------------- #
-def _get(url, timeout=30):
- req = urllib.request.Request(url, headers=UA)
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- return resp.read()
-
-
-def fetch_text(url):
- try:
- return _get(url).decode("utf-8", errors="replace")
- except Exception as e: # noqa: BLE001
- print(f" ! could not fetch {url}: {e}", file=sys.stderr)
- return None
-
-
-def fetch_json(url):
- """Fetch and parse JSON regardless of the server's Content-Type header."""
- try:
- return json.loads(_get(url))
- except Exception as e: # noqa: BLE001
- print(f" ! CSAF unavailable ({url}): {e}", file=sys.stderr)
- return None
-
-
-def csaf_url(slug):
- return f"{BASE}/security/center/content/CiscoSecurityAdvisory/{slug}/csaf/{slug}.json"
-
-
-# --------------------------------------------------------------------------- #
-# Advisory discovery
-# --------------------------------------------------------------------------- #
-def discover_advisories(url):
- """Return an ordered, de-duplicated list of advisory slugs.
-
- Accepts an ERP page URL (many advisories) or a single advisory URL.
- """
- direct = SLUG_RE.search(url)
- if "viewErp" not in url and direct:
- return [direct.group(1)]
- html = fetch_text(url)
- if not html:
- return [direct.group(1)] if direct else []
- seen, out = set(), []
- for slug in SLUG_RE.findall(html):
- slug = slug.split("/")[0] # drop any trailing path (e.g. /csaf)
- if slug not in seen:
- seen.add(slug)
- out.append(slug)
- return out
-
-
-# --------------------------------------------------------------------------- #
-# Family detection
-# --------------------------------------------------------------------------- #
-def detect_family(name):
- n = (name or "").lower()
- if "management center" in n or re.search(r"\bfmc\b", n):
- return "FMC"
- if "threat defense" in n or re.search(r"\bftd\b", n):
- return "FTD"
- if "adaptive security appliance" in n or re.search(r"\basav?\b", n):
- return "ASA"
- if "snort" in n:
- return "SNORT"
- return "OTHER"
-
-
-# --------------------------------------------------------------------------- #
-# Text cleaning
-# --------------------------------------------------------------------------- #
-def _clean(text):
- text = _html.unescape(text or "")
- text = re.sub(r" ", "\n", text)
- text = re.sub(r"<[^>]+>", "", text)
- # CSAF notes embed hyperlink anchors as ["#fs"] style tokens; drop them.
- text = re.sub(r'\["#[^"]*"\]', "", text)
- text = re.sub(r"[ \t]+", " ", text)
- return re.sub(r"\n{3,}", "\n\n", text).strip()
-
-
-# --------------------------------------------------------------------------- #
-# CSAF extraction
-# --------------------------------------------------------------------------- #
-def _all_notes(csaf):
- notes = list(csaf.get("document", {}).get("notes", []) or [])
- for v in csaf.get("vulnerabilities", []) or []:
- notes += v.get("notes", []) or []
- return notes
-
-
-def get_note(csaf, title):
- """Return the cleaned text of the note whose title matches (case-insensitive)."""
- want = title.strip().lower()
- for n in _all_notes(csaf):
- if (n.get("title") or "").strip().lower() == want:
- return _clean(n.get("text", ""))
- return ""
-
-
-def advisory_id(csaf):
- return (csaf.get("document", {}).get("tracking", {}).get("id") or "").strip()
-
-
-def advisory_title(csaf):
- return (csaf.get("document", {}).get("title") or "").strip()
-
-
-def _build_leaf_map(branches, family_name, out):
- """Map every product leaf id -> (family, version_string).
-
- Family comes from the nearest product_family / product_name ancestor; the
- version string is the leaf's own product name (e.g. "7.0.0").
- """
- for b in branches or []:
- cat = b.get("category")
- name = b.get("name", "")
- fam_name = name if cat in ("product_family", "product_name") else family_name
- product = b.get("product")
- if product and product.get("product_id"):
- version = product.get("name") or name
- out[product["product_id"]] = (detect_family(fam_name or name), version)
- _build_leaf_map(b.get("branches"), fam_name, out)
- return out
-
-
-def parse_csaf_affected_versions(csaf):
- """Return {family: sorted[version_str]} for the matchable families.
-
- Handles both CSAF product_tree shapes:
- * relationships present: known_affected holds composite ids
- (CSAFPID-:) that resolve via relationships[].product_reference
- to the version leaf.
- * no relationships: known_affected points directly at leaf product ids.
- """
- tree = csaf.get("product_tree", {}) or {}
- leaf = _build_leaf_map(tree.get("branches"), None, {})
-
- rel = {}
- for r in tree.get("relationships", []) or []:
- fpn = (r.get("full_product_name") or {}).get("product_id")
- if fpn:
- rel[fpn] = r.get("product_reference")
-
- out = {}
- for vuln in csaf.get("vulnerabilities", []) or []:
- for pid in vuln.get("product_status", {}).get("known_affected", []) or []:
- base = rel.get(pid) or pid.split(":")[0]
- fam, ver = leaf.get(base) or leaf.get(pid) or (None, None)
- if fam in MATCHABLE_FAMILIES and ver:
- out.setdefault(fam, set()).add(ver)
-
- return {fam: sorted(vs, key=_version_key) for fam, vs in out.items()}
-
-
-def _version_key(s):
- return tuple(int(x) for x in re.findall(r"\d+", str(s)))
-
-
-def extract_advisory(csaf, slug=None):
- """Collapse a CSAF document into the fields the analyzer needs."""
- return {
- "id": advisory_id(csaf) or (slug or ""),
- "title": advisory_title(csaf),
- "vulnerable_products": get_note(csaf, "Vulnerable Products"),
- "not_vulnerable": get_note(csaf, "Products Confirmed Not Vulnerable"),
- "affected_versions": parse_csaf_affected_versions(csaf),
- }
diff --git a/caia/cli.py b/caia/cli.py
index af1b31d..b5f7764 100644
--- a/caia/cli.py
+++ b/caia/cli.py
@@ -1,9 +1,9 @@
-"""Single command-line entry point for the Cisco Advisory Impact Agent.
+"""Single command-line entry point for the Cisco Advisory Impact Analyzer.
-Installed as the `caia` console script (see pyproject.toml). Dispatches
-`--help`/`--version`/`--update`/`--uninstall`/`--config` and, with no mode flag, the
-interactive/flag-driven analysis run. Ctrl+C anywhere exits 130 with no traceback
-(Constitution III, FR-012).
+Installed as the `caia` console script (see pyproject.toml). Refuses any withdrawn option
+first (see `refuse_removed_options`), then dispatches
+`--help`/`--version`/`--update`/`--uninstall`/`--config` and, with no other flag, the
+inventory-driven check. Ctrl+C anywhere exits 130 with no traceback (Constitution III).
"""
from __future__ import annotations
@@ -15,6 +15,45 @@
from caia import analyzer, config, ui, version
+# Options this tool used to accept, with what to do instead. Checked before the parser is
+# built, because argparse gives `--help` precedence over a merely-absent option: with `--api`
+# simply removed, `caia --api --help` would print help and exit 0, and
+# `caia --uninstall --dry-run` would really uninstall. A withdrawn option must outrank
+# everything else so a stale command line can never trigger an unintended action
+# (spec 007 FR-010, FR-011; research D1).
+_RUN_INSTEAD = "Just run: " + "caia"
+REMOVED_OPTIONS = {
+ "--api": ("Checking your whole inventory against Cisco's published advisory data is now "
+ "what caia does by default. " + _RUN_INSTEAD),
+ "--url": ("The advisory-driven analysis no longer exists; caia now checks your whole "
+ "inventory against Cisco's published advisory data. " + _RUN_INSTEAD),
+ "--keep-temp": "caia no longer downloads advisory files, so there is nothing to keep.",
+ "--no-keep-temp": "caia no longer downloads advisory files, so there is nothing to delete.",
+ "--dry-run": ("caia no longer offers a preview; every run contacts Cisco and needs your "
+ "credentials."),
+}
+
+
+def refuse_removed_options(argv):
+ """Refuse a withdrawn option before anything else happens. Returns an exit code or None.
+
+ Matching is exact — including the `--url=VALUE` form — so an abbreviation or misspelling
+ such as `--dry` still reaches argparse and gets the ordinary unrecognised-argument error.
+ That keeps this message meaningful: it appears only for options that genuinely existed
+ (spec 007 FR-012; research D2). The exit status is argparse's own for an argument error,
+ since that is what this is (FR-010; research D3).
+ """
+ for arg in argv:
+ name = arg.split("=", 1)[0] if arg.startswith("--") and "=" in arg else arg
+ guidance = REMOVED_OPTIONS.get(name)
+ if guidance is None:
+ continue
+ ui.fail(f"{name} has been removed.")
+ ui.plain(ui.dim(guidance))
+ return 2
+ return None
+
+
class _Parser(argparse.ArgumentParser):
"""ArgumentParser whose `--help` omits the `usage:` synopsis.
@@ -49,8 +88,8 @@ def error(self, message):
def build_parser():
ap = _Parser(
prog="caia",
- description="AI-driven analysis of which firewalls in your inventory are impacted "
- "by a Cisco security advisory.",
+ description="Checks every firewall in your inventory against Cisco's published "
+ "advisory data, and writes an HTML impact report.",
)
# Mode flags (handled in precedence order; not combined).
ap.add_argument("--version", action="store_true",
@@ -60,24 +99,15 @@ def build_parser():
ap.add_argument("--uninstall", action="store_true",
help="Remove caia via uv (your saved config is kept)")
ap.add_argument("--config", dest="do_config", action="store_true",
- help="Set your FueliX API key and model (stored per-user)")
+ help="Set your Cisco PSIRT API credentials (stored per-user)")
ap.add_argument("--yes", "-y", dest="yes", action="store_true",
- help="Skip confirmation prompts (for scripted/unattended use)")
- # Analysis flags (a supplied flag skips its interactive prompt — FR-025).
- ap.add_argument("--api", action="store_true",
- help="Check your whole inventory against Cisco's published advisory "
- "data (no URL needed); writes an HTML report")
- ap.add_argument("--url", help="ERP page URL or single advisory URL (else prompted)")
+ help="Skip the uninstall confirmation (for scripted/unattended use)")
+ # Analysis flags (a supplied flag skips its interactive prompt).
ap.add_argument("--inventory",
help="Path to an inventory .xlsx (else discovered in the current folder)")
ap.add_argument("--sheet", default="FW_List", help="Inventory sheet name")
ap.add_argument("--output-dir", dest="output_dir", default=None,
help="Where to write the report (default: ./output in the current folder)")
- ap.add_argument("--dry-run", action="store_true", help="Skip the AI call (pipeline test)")
- ap.add_argument("--keep-temp", dest="keep_temp", action="store_true", default=True,
- help="Keep downloaded CSAF temp files (default)")
- ap.add_argument("--no-keep-temp", dest="keep_temp", action="store_false",
- help="Delete downloaded CSAF temp files after the run")
ap.add_argument("--no-update-check", dest="no_update_check", action="store_true",
help="Skip the start-of-run check for a newer version")
return ap
@@ -148,7 +178,7 @@ def _report_preserved_config():
"""Tell the user where their saved settings remain (never deleted; contents never read)."""
path = config.config_path()
if path.exists():
- ui.plain("Your saved settings (API key and model) were left untouched at:")
+ ui.plain("Your saved Cisco credentials were left untouched at:")
ui.plain(" " + ui.bold(str(path)))
ui.plain(ui.dim("Delete that file yourself if you want it gone."))
else:
@@ -207,13 +237,15 @@ def cmd_uninstall(args):
def _start_of_run_update_nudge(args):
"""Best-effort, non-blocking update offer at the start of an interactive run (FR-013).
- Skipped when --no-update-check / CAIA_NO_UPDATE_CHECK is set, when not attached to a TTY, or
- in a flag-driven run (a URL was supplied). Answering yes updates and exits (the running
- process is the old code); answering no continues on the current version.
+ Skipped when --no-update-check / CAIA_NO_UPDATE_CHECK is set, or when not attached to a
+ TTY. Which analysis options were supplied is irrelevant: the TTY check already excludes
+ unattended runs, and an interactive user is exactly who should hear about an update
+ (spec 007 FR-015). Answering yes updates and exits (the running process is the old
+ code); answering no continues on the current version.
"""
if args.no_update_check or os.environ.get("CAIA_NO_UPDATE_CHECK"):
return
- if args.url or not sys.stdin.isatty():
+ if not sys.stdin.isatty():
return
newer = version.passive_check(timeout=2)
if not newer:
@@ -234,22 +266,8 @@ def _start_of_run_update_nudge(args):
def cmd_run(args):
- """Dispatch to the inventory-driven check or the advisory-driven analysis.
-
- The two are mutually exclusive: they answer different questions and produce different
- reports, so combining them is a mistake worth naming rather than silently resolving
- (FR-003).
- """
- if args.api and args.url:
- ui.fail("--api and --url cannot be combined.")
- ui.plain(ui.dim(
- "--api checks your whole inventory against Cisco's published advisory data.\n"
- "--url analyzes one advisory or ERP page against your inventory.\n"
- "Run one or the other."))
- return 2
+ """Run the inventory-driven check — the tool's only analysis behaviour."""
_start_of_run_update_nudge(args)
- if args.api:
- return analyzer.run_api(args)
return analyzer.run(args)
@@ -260,6 +278,9 @@ def main(argv=None):
"""Console-script entry point. Returns a process exit code."""
try:
ui.banner() # splash shown once per invocation (interactive TTY only; see CAIA_NO_BANNER)
+ removed = refuse_removed_options(sys.argv[1:] if argv is None else argv)
+ if removed is not None:
+ return removed
return _dispatch(argv)
except KeyboardInterrupt:
print()
diff --git a/caia/config.py b/caia/config.py
index 47e1313..c55a53b 100644
--- a/caia/config.py
+++ b/caia/config.py
@@ -1,9 +1,9 @@
-"""Per-user configuration: FueliX API key, model, and base URL.
+"""Per-user configuration: the Cisco PSIRT API credentials.
Stored in an OS-appropriate per-user location (not any working folder), readable by later runs
from any folder. Resolution precedence for every setting is: environment variable -> per-user
-config file -> built-in default. Standard library only for path/file handling; the API key file
-is owner-only where the OS supports it. See specs/004-uv-tool-distribution/contracts/config.md.
+config file -> built-in default. Standard library only for path/file handling; the file is
+owner-only where the OS supports it. See specs/007-decommission-ai-analyzer/contracts/config-file.md.
"""
from __future__ import annotations
@@ -12,23 +12,22 @@
import sys
from pathlib import Path
-from caia import fuelix, ui
+from caia import ui
APP_DIR_NAME = "caia"
CONFIG_FILE_NAME = "config"
-# Keys managed here, mirroring the environment-variable names the app already honors.
-API_KEY = "FUELIX_API_KEY"
-MODEL = "FUELIX_MODEL"
-BASE_URL = "FUELIX_BASE_URL"
-# Cisco PSIRT API credentials, used only by the inventory-driven check (`--api`).
+# Keys managed here, mirroring the environment-variable names the app honors.
# The client id is an application identifier, not a secret; the client secret is a secret
# and is handled as one throughout (never echoed, logged, or written outside this file).
CISCO_CLIENT_ID = "CISCO_CLIENT_ID"
CISCO_CLIENT_SECRET = "CISCO_CLIENT_SECRET"
-KEYS = (API_KEY, MODEL, BASE_URL, CISCO_CLIENT_ID, CISCO_CLIENT_SECRET)
+KEYS = (CISCO_CLIENT_ID, CISCO_CLIENT_SECRET)
-DEFAULTS = {MODEL: fuelix.DEFAULT_MODEL, BASE_URL: fuelix.DEFAULT_BASE_URL}
+# Nothing the product manages has a built-in default; an absent setting resolves to "".
+# Settings an earlier version managed are not listed here, so they are never read and are
+# dropped from the file the next time it is saved (spec 007 FR-018, FR-019).
+DEFAULTS = {}
# --------------------------------------------------------------------------- #
@@ -83,11 +82,10 @@ def save(values):
path = config_path()
lines = [
- "# Cisco Advisory Impact Agent configuration.",
+ "# Cisco Advisory Impact Analyzer configuration.",
"# Managed by `caia --config`; you may edit values by hand.",
"# Environment variables of the same name override every value below:",
- "# FUELIX_API_KEY / FUELIX_MODEL / FUELIX_BASE_URL - advisory-driven analysis",
- "# CISCO_CLIENT_ID / CISCO_CLIENT_SECRET - inventory-driven check (--api)",
+ "# CISCO_CLIENT_ID / CISCO_CLIENT_SECRET - Cisco PSIRT API access",
]
for key in KEYS:
val = values.get(key)
@@ -214,62 +212,39 @@ def _verify_cisco(client_id, client_secret, interactive):
def run_config():
- """Interactively set every credential and setting; persist to the per-user config file.
+ """Interactively set the Cisco credentials; persist to the per-user config file.
- One linear pass, every prompt skippable. Each mode gates only on its own credentials,
- so a user of `--api` alone never has to supply an AI service key and vice versa
- (spec 006 FR-034..FR-036).
+ One linear pass, every prompt skippable, so a user can walk past a value they intend to
+ supply later (spec 007 FR-016, FR-017). Settings an earlier version managed are neither
+ prompted for nor carried forward: `save` writes only the keys in `KEYS`, so they simply
+ disappear from the file (FR-018).
"""
existing = load_file()
interactive = sys.stdin.isatty()
- ui.title("Configure Cisco Advisory Impact Agent")
+ ui.title("Configure Cisco Advisory Impact Analyzer")
- # --- FueliX (advisory-driven analysis) ------------------------------------ #
- ui.system("FueliX AI — used by the advisory-driven analysis (caia --url).")
- api_key = _prompt_secret("FueliX API key", existing.get(API_KEY))
-
- current_model = existing.get(MODEL, fuelix.DEFAULT_MODEL)
- options = list(fuelix.KNOWN_MODELS)
- if current_model not in options:
- options = [current_model] + options
- ui.plain()
- ui.system("Choose the FueliX model:")
- model = ui.select("Model", options, default=current_model)
-
- # Base URL is not prompted (FR-010); preserve the existing/default so the file stays editable.
- base_url = existing.get(BASE_URL, fuelix.DEFAULT_BASE_URL)
-
- # --- Cisco PSIRT API (inventory-driven check) ---------------------------- #
- ui.plain()
- ui.system("Cisco PSIRT API — used by the inventory-driven check (caia --api).")
+ ui.system("Cisco PSIRT API — the source of every verdict.")
ui.plain(ui.dim("Get your own client ID and secret from https://developer.cisco.com/psirt/"))
# The client ID names the calling application and is not a secret, so it is echoed:
- # seeing it lets you confirm a paste landed. The secret is never displayed (FR-037).
+ # seeing it lets you confirm a paste landed. The secret is never displayed (FR-017).
cisco_id = _prompt_visible("Cisco client ID", existing.get(CISCO_CLIENT_ID))
cisco_secret = _prompt_secret("Cisco client secret", existing.get(CISCO_CLIENT_SECRET))
- # Verify only when both values are available for this session (FR-044).
+ # Verify only when both values are available for this session (FR-017).
if cisco_id and cisco_secret:
if not _verify_cisco(cisco_id, cisco_secret, interactive):
ui.plain()
ui.warn("The Cisco credentials were not saved. Re-run "
+ ui.bold("caia --config") + " to enter them again.")
- cisco_id = existing.get(CISCO_CLIENT_ID, "")
- cisco_secret = existing.get(CISCO_CLIENT_SECRET, "")
- path = save({API_KEY: api_key, MODEL: model, BASE_URL: base_url,
- CISCO_CLIENT_ID: cisco_id, CISCO_CLIENT_SECRET: cisco_secret})
- ui.plain(ui.dim(f"Other settings were saved to {path}."))
+ path = save({CISCO_CLIENT_ID: existing.get(CISCO_CLIENT_ID, ""),
+ CISCO_CLIENT_SECRET: existing.get(CISCO_CLIENT_SECRET, "")})
+ ui.plain(ui.dim(f"Previously saved values were kept in {path}."))
return 1
- path = save({API_KEY: api_key, MODEL: model, BASE_URL: base_url,
- CISCO_CLIENT_ID: cisco_id, CISCO_CLIENT_SECRET: cisco_secret})
+ path = save({CISCO_CLIENT_ID: cisco_id, CISCO_CLIENT_SECRET: cisco_secret})
ui.plain()
ui.ok("Saved configuration to " + ui.bold(str(path)) + ".")
- ui.plain(ui.dim("The base URL is not prompted; edit the file or set FUELIX_BASE_URL to change it."))
- if not api_key:
- ui.plain(ui.dim("No FueliX key was set, so the advisory-driven analysis "
- "(caia --url) will ask for one when you run it."))
if not (cisco_id and cisco_secret):
- ui.plain(ui.dim("Cisco credentials are incomplete, so caia --api will ask for "
+ ui.plain(ui.dim("Cisco credentials are incomplete, so caia will ask for "
"them when you run it."))
return 0
diff --git a/caia/fuelix.py b/caia/fuelix.py
deleted file mode 100644
index b2f78a5..0000000
--- a/caia/fuelix.py
+++ /dev/null
@@ -1,217 +0,0 @@
-"""
-FueliX (Fuel iX) AI client.
-
-FueliX exposes an OpenAI-compatible Chat Completions endpoint, so we POST a standard
-{model, messages, ...} body and read choices[0].message.content. Only the standard
-library is used for the HTTP call.
-
-The public entry point is assess_advisory(): it asks the model, for one advisory, which
-inventory combos are impacted and returns a normalized dict.
-"""
-
-from __future__ import annotations
-
-import json
-import re
-import time
-import urllib.error
-import urllib.request
-
-DEFAULT_BASE_URL = "https://api.fuelix.ai/v1"
-DEFAULT_MODEL = "claude-sonnet-5"
-
-# Curated list of known-good FueliX models offered by `--config` (FR-009). Maintained in the
-# product and kept current with what FueliX exposes; DEFAULT_MODEL MUST be the first entry.
-# A previously-configured model not in this list is preserved rather than discarded.
-KNOWN_MODELS = [
- "claude-sonnet-5",
- "claude-opus-4-8",
- "claude-haiku-4-5",
- "claude-fable-5",
-]
-
-SYSTEM_PROMPT = """\
-You are a Cisco firewall vulnerability impact analyst. You are given ONE Cisco security \
-advisory and a firewall inventory that contains ONLY ASA and FTD devices. Decide which \
-inventory devices are impacted by that advisory.
-
-Apply these rules IN ORDER and stop at the first that matches:
-1. If the impact depends on runtime configuration that the inventory does NOT track \
-(for example: high availability / HA, file policy, remote access VPN, SAML SSO, Snort \
-detection engine settings, "if configured", "if enabled", or "if it was managing" \
-specific devices) -> category "indeterminate".
-2. If the advisory affects ONLY product families we cannot match against this inventory \
-(Cisco Secure Firewall Management Center / FMC software, Cloud-Delivered FMC / cdFMC, \
-Security Cloud Control / SCC, or Open Source Snort) -> "indeterminate". The inventory has \
-no FMC, cdFMC, SCC or Snort devices.
-3. If a matchable family (ASA or FTD) is affected but there are NO specific affected \
-software releases you can enumerate -> "indeterminate".
-4. Otherwise, match the affected releases against each inventory combo's version. Return \
-the ids of the combos whose family AND version are affected -> category "affected". If a \
-matchable family is affected with enumerable releases but no inventory combo matches -> \
-"not_affected".
-
-Version matching guidance: Cisco versions are irregular (e.g. 7.4.2.3, 9.16(4)67, \
-9.20.4.14). Compare them numerically, segment by segment. A device build that is a more \
-specific extension of an affected release counts as a match (device 7.4.2.1-30 matches \
-affected 7.4.2.1). Honor "X to Y" release ranges inclusively. Only match a combo whose \
-family equals the affected family (do not match an ASA combo against an FTD release).
-
-Return STRICT JSON only. No prose, no markdown, no code fences. Exactly this shape:
-{"category": "affected" | "not_affected" | "indeterminate",
- "impacted_combo_ids": [, ...],
- "summary": "<1-2 sentence description of the affected products>",
- "reasoning": ""}
-When category is not "affected", impacted_combo_ids must be []."""
-
-
-def build_user_prompt(adv, combos):
- av = adv.get("affected_versions") or {}
- if av:
- releases = "\n".join(
- f" {fam}: {', '.join(vers)}" for fam, vers in sorted(av.items())
- )
- else:
- releases = " (none enumerated in the CSAF product data)"
-
- combo_lines = "\n".join(
- f"{c['id']} | {c['model']} | {c['family']} | {c['version']} | {c['count']}"
- for c in combos
- )
-
- return f"""\
-ADVISORY ID: {adv.get('id', '')}
-TITLE: {adv.get('title', '')}
-
-VULNERABLE PRODUCTS:
-{adv.get('vulnerable_products') or '(section not present)'}
-
-PRODUCTS CONFIRMED NOT VULNERABLE:
-{adv.get('not_vulnerable') or '(section not present)'}
-
-ENUMERATED AFFECTED RELEASES (from CSAF product data):
-{releases}
-
-INVENTORY COMBOS (id | model | type | version | device_count):
-{combo_lines}
-
-Decide the category and, if "affected", the impacted combo ids. Return STRICT JSON only."""
-
-
-# --------------------------------------------------------------------------- #
-# HTTP
-# --------------------------------------------------------------------------- #
-def chat(messages, api_key, model=DEFAULT_MODEL, base_url=DEFAULT_BASE_URL,
- temperature=None, timeout=90, retries=2):
- """Call the OpenAI-compatible chat completions endpoint; return the reply text.
-
- temperature is omitted unless explicitly provided — some FueliX-hosted models
- (e.g. Claude via Vertex) reject it as deprecated.
- """
- url = base_url.rstrip("/") + "/chat/completions"
- payload = {"model": model, "messages": messages}
- if temperature is not None:
- payload["temperature"] = temperature
- body = json.dumps(payload).encode("utf-8")
- headers = {
- "Authorization": f"Bearer {api_key}",
- "Content-Type": "application/json",
- "Accept": "application/json",
- # A browser-like UA avoids Cloudflare's default bot block (error 1010) that
- # rejects Python's stdlib "Python-urllib/x.y" signature.
- "User-Agent": (
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/122.0.0.0 Safari/537.36"
- ),
- }
-
- last_err = None
- for attempt in range(retries + 1):
- req = urllib.request.Request(url, data=body, headers=headers, method="POST")
- try:
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- data = json.loads(resp.read())
- return data["choices"][0]["message"]["content"]
- except urllib.error.HTTPError as e:
- detail = _read_error(e)
- # 4xx (except 429) are not worth retrying — surface immediately.
- if e.code != 429 and 400 <= e.code < 500:
- raise RuntimeError(
- f"FueliX API error {e.code}: {detail}"
- ) from e
- last_err = RuntimeError(f"FueliX API error {e.code}: {detail}")
- except (urllib.error.URLError, TimeoutError, KeyError, ValueError) as e:
- last_err = RuntimeError(f"FueliX request failed: {e}")
- if attempt < retries:
- time.sleep(1.5 * (attempt + 1))
- raise last_err
-
-
-def _read_error(e):
- try:
- return e.read().decode("utf-8", errors="replace")[:500]
- except Exception: # noqa: BLE001
- return str(e)
-
-
-# --------------------------------------------------------------------------- #
-# JSON parsing
-# --------------------------------------------------------------------------- #
-def _parse_json(text):
- """Extract the first JSON object from a model reply, tolerating code fences."""
- t = (text or "").strip()
- if t.startswith("```"):
- t = re.sub(r"^```[a-zA-Z]*\n", "", t)
- t = re.sub(r"\n```$", "", t).strip()
- try:
- return json.loads(t)
- except ValueError:
- pass
- start = t.find("{")
- end = t.rfind("}")
- if start != -1 and end > start:
- return json.loads(t[start : end + 1])
- raise ValueError("no JSON object found in model reply")
-
-
-def _normalize(obj):
- category = str(obj.get("category", "")).strip().lower()
- if category not in ("affected", "not_affected", "indeterminate"):
- category = "indeterminate"
- ids = obj.get("impacted_combo_ids") or []
- if not isinstance(ids, list):
- ids = []
- if category != "affected":
- ids = []
- return {
- "category": category,
- "impacted_combo_ids": ids,
- "summary": str(obj.get("summary", "")).strip(),
- "reasoning": str(obj.get("reasoning", "")).strip(),
- }
-
-
-# --------------------------------------------------------------------------- #
-# Public entry point
-# --------------------------------------------------------------------------- #
-def assess_advisory(adv, combos, api_key, model=DEFAULT_MODEL,
- base_url=DEFAULT_BASE_URL, timeout=90):
- """Ask the model to assess one advisory. Returns the normalized dict.
-
- On a JSON-parse failure the call is retried once with a stricter reminder.
- """
- messages = [
- {"role": "system", "content": SYSTEM_PROMPT},
- {"role": "user", "content": build_user_prompt(adv, combos)},
- ]
- reply = chat(messages, api_key, model=model, base_url=base_url, timeout=timeout)
- try:
- return _normalize(_parse_json(reply))
- except ValueError:
- messages.append({"role": "assistant", "content": reply})
- messages.append(
- {"role": "user", "content": "Return ONLY the JSON object, nothing else."}
- )
- reply = chat(messages, api_key, model=model, base_url=base_url, timeout=timeout)
- return _normalize(_parse_json(reply))
diff --git a/caia/inventory.py b/caia/inventory.py
index 37d0234..a9194d3 100644
--- a/caia/inventory.py
+++ b/caia/inventory.py
@@ -1,10 +1,10 @@
"""
-Firewall inventory loading and compaction.
+Firewall inventory loading.
-The inventory is a spreadsheet (~1000 rows) with one row per firewall. For the AI we
-don't send every row; we collapse identical (model, family, version) rows into
-"combos" with a stable integer id and send only those. The analyzer expands the AI's
-chosen combo ids back into the full list of firewall names.
+The inventory is a spreadsheet (~1000 rows) with one row per firewall. Each row is read
+into a dict carrying the fields the check needs, plus the sheet's own columns verbatim so
+the report can reproduce them. Devices sharing a family, platform and release are
+collapsed into one question to Cisco later, in `matching.build_checks`.
"""
from __future__ import annotations
@@ -97,47 +97,3 @@ def _cell(row, idx):
if idx is None or idx >= len(row):
return None
return row[idx]
-
-
-def build_combos(inventory):
- """Collapse the inventory into distinct (model, family, version) combos.
-
- Returns (combos, combo_map):
- combos : list of {id, model, family, version, count} ordered by id
- combo_map : {id -> [firewall_name, ...]}
- """
- order = []
- index = {}
- combo_map = {}
- for fw in inventory:
- key = (fw["model"], fw["family"], fw["version"])
- if key not in index:
- cid = len(order)
- index[key] = cid
- order.append(key)
- combo_map[cid] = []
- combo_map[index[key]].append(fw["name"])
-
- combos = [
- {
- "id": cid,
- "model": model,
- "family": family,
- "version": version,
- "count": len(combo_map[cid]),
- }
- for cid, (model, family, version) in enumerate(order)
- ]
- return combos, combo_map
-
-
-def expand_combo_ids(combo_ids, combo_map):
- """Turn a list of combo ids into a sorted, de-duplicated list of firewall names."""
- names = set()
- for cid in combo_ids or []:
- try:
- cid = int(cid)
- except (TypeError, ValueError):
- continue
- names.update(combo_map.get(cid, []))
- return sorted(names)
diff --git a/caia/matching.py b/caia/matching.py
index 96d27fb..1557288 100644
--- a/caia/matching.py
+++ b/caia/matching.py
@@ -235,16 +235,14 @@ def __repr__(self): # pragma: no cover - debugging aid
# --------------------------------------------------------------------------- #
# Device resolution (outcome table rows 1-3, resolved locally, no request)
# --------------------------------------------------------------------------- #
-def resolve_device(device, catalogues, validate_alias=True):
+def resolve_device(device, catalogues):
"""Resolve a device to the Cisco query it needs, or to a local NEEDS_REVIEW reason.
Returns (check, reason) with exactly one of the two set. `check` is the
(os_type, platform_alias, release) tuple identifying the question to ask.
- `validate_alias=False` skips the check that Cisco currently lists the mapped alias.
- Only the offline dry run uses that, because it has no catalogues to validate against;
- a real run always validates, so a platform Cisco has not aliased yet cannot silently
- become a query.
+ A mapped alias is always validated against Cisco's catalogue, so a platform Cisco has
+ not aliased yet cannot silently become a query.
"""
family = str(device.get("family") or "").strip()
model = str(device.get("model") or "").strip()
@@ -265,9 +263,7 @@ def resolve_device(device, catalogues, validate_alias=True):
# Row 3 - the model maps to no alias, or to one Cisco does not list.
if family_takes_platform(os_type):
alias = platform_alias_for(model, os_type)
- unknown = alias is None or (validate_alias
- and not catalogues.known_alias(os_type, alias))
- if unknown:
+ if alias is None or not catalogues.known_alias(os_type, alias):
return None, Reason(MODEL_UNMAPPED, model=model or "(blank)")
else:
alias = None
@@ -314,7 +310,7 @@ def verdict_from_outcome(check, catalogues, outcome, advisories=None, raw_releas
return NEEDS_REVIEW, Reason(CHECK_INCOMPLETE)
-def build_checks(devices, catalogues, validate_alias=True):
+def build_checks(devices, catalogues):
"""Collapse devices into the distinct questions Cisco needs to be asked.
Returns (checks, unresolved) where `checks` is an ordered {check tuple: [device, ...]}
@@ -325,7 +321,7 @@ def build_checks(devices, catalogues, validate_alias=True):
checks = {}
unresolved = []
for device in devices:
- check, reason = resolve_device(device, catalogues, validate_alias=validate_alias)
+ check, reason = resolve_device(device, catalogues)
if reason is not None:
unresolved.append((device, reason))
continue
diff --git a/caia/psirt.py b/caia/psirt.py
index b8a73e2..018f880 100644
--- a/caia/psirt.py
+++ b/caia/psirt.py
@@ -1,7 +1,7 @@
"""Cisco PSIRT openVuln API access for the inventory-driven check (`--api`).
-The only module that talks to Cisco's advisory API — the counterpart of `cisco.py`
-(ERP/CSAF) and `fuelix.py` (AI). Standard library only.
+The only module that talks to Cisco's advisory API, and the source of every verdict.
+Standard library only.
Two behaviours here are load-bearing and were established against the live API rather
than from its documentation (see contracts/psirt-access.md):
diff --git a/caia/report.py b/caia/report.py
deleted file mode 100644
index a68da42..0000000
--- a/caia/report.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""
-Excel report writer.
-
-Produces analysis_output_.xlsx with three columns matching the team's
-reference format: Vendor Advisory#, Effected Product Description, Expected Assessment.
-"""
-
-from __future__ import annotations
-
-import datetime as _dt
-from pathlib import Path
-
-import openpyxl
-from openpyxl.styles import Alignment, Font, PatternFill
-
-HEADERS = ["Vendor Advisory#", "Effected Product Description", "Expected Assessment"]
-
-
-def write_report(results, output_dir="output"):
- """Write results (list of (advisory_id, description, assessment)) to a new xlsx.
-
- The output directory is created if missing. The file name carries a timestamp so
- reports accumulate; if a name already exists (e.g. two runs within the same second)
- a numeric suffix is appended so an existing report is never overwritten.
-
- Returns the path written.
- """
- out_dir = Path(output_dir)
- out_dir.mkdir(parents=True, exist_ok=True)
- ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
- out_path = out_dir / f"analysis_output_{ts}.xlsx"
- n = 1
- while out_path.exists():
- out_path = out_dir / f"analysis_output_{ts}_{n}.xlsx"
- n += 1
-
- wb = openpyxl.Workbook()
- ws = wb.active
- ws.title = "Analysis"
- ws.append(HEADERS)
-
- header_fill = PatternFill("solid", fgColor="1F4E78")
- for cell in ws[1]:
- cell.font = Font(bold=True, color="FFFFFF")
- cell.fill = header_fill
- cell.alignment = Alignment(vertical="center")
-
- for advisory_id, description, assessment in results:
- ws.append([advisory_id, description, assessment])
-
- for row in ws.iter_rows(min_row=2):
- for cell in row:
- cell.alignment = Alignment(wrap_text=True, vertical="top")
-
- ws.column_dimensions["A"].width = 42
- ws.column_dimensions["B"].width = 70
- ws.column_dimensions["C"].width = 30
- ws.freeze_panes = "A2"
-
- wb.save(out_path)
- return str(out_path)
diff --git a/caia/ui.py b/caia/ui.py
index 0868fff..09c87ce 100644
--- a/caia/ui.py
+++ b/caia/ui.py
@@ -168,8 +168,8 @@ def plain(msg=""):
# 256-colour gradient painted across the four letters (green → teal → blue → purple).
_GRADIENT = [46, 44, 39, 129]
_WORDMARK = "CAIA"
-_NAME = "Cisco Advisory Impact Agent"
-_SLOGAN = "Agentic firewall impact analysis for Cisco security advisories · TELUS Digital"
+_NAME = "Cisco Advisory Impact Analyzer"
+_SLOGAN = "Fleet-wide firewall impact analysis, straight from Cisco · TELUS Digital"
def _c256(code, text):
diff --git a/docs/index.html b/docs/index.html
index ac01350..fe601e6 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -3,8 +3,8 @@
-Cisco Advisory Impact Agent
-
+Cisco Advisory Impact Analyzer
+