Phoenix Public API

This document describes the public API surface exposed to customers.

Base URL

/api/v1

Authentication

Public APIs accept:

Public APIs enforce tier-based response filtering for sensitive data.

Scoring formulas, weights, and internal rationale are not exposed in the public API.

Swagger

Public Swagger UI is available in debug/non-production environments only (returns 404 in production):

The public OpenAPI schema is built from a default-deny allow-list of public endpoint prefixes. Admin (/api/v1/admin/*), any path with an /admin segment, the Vuln Weekly pipeline (/api/v1/vw/*), the MPI admin backend (/api/v1/malware-intel/*), exploit-recreation actions, and all /internal/* PAI routes are excluded — a new router does not appear in the public docs until explicitly allow-listed. The internal API docs (/internal/docs, /internal/openapi.json, /internal/redoc) are also debug-only and 404 in production.

Tiered Access

Tier Access
FREEMinimal fields
REGISTEREDStandard fields
PROExpanded fields
ENTERPRISEFull fields (public plane only)

Core Endpoints

CVE Search & Intelligence

GET /api/v1/cves

The base collection route is the CVE search endpoint (there is no /cves/search path). Available to FREE (subject to the search daily quota); results are tier-filtered.

ParamTypeNotes
qstringCVE ID prefix or description substring
yearint (1999–2030)Filter by CVE year
severitystringCRITICAL / HIGH / MEDIUM / LOW
kev_onlyboolOnly CISA KEV CVEs
epss_minfloat (0–1)Minimum EPSS score
sortstringpublished[:desc|:asc] or cvss[:desc|:asc]; default year-descending
published_afterYYYY-MM-DDInclusive lower publish-date bound
published_beforeYYYY-MM-DDInclusive upper publish-date bound
has_analysisbooltrue → only Phoenix-analyzed CVEs; false → only un-analyzed
limitint (1–200)Page size (Data-Shield pagination applies)
offsetintPagination offset

Every result carries a has_analysis flag. Recipes:

Related FREE reads: GET /api/v1/cves/by-product?vendor=&product=, /cves/trending, /cves/kev, /cves/homepage/intel, /cves/stats/{year}. Curated "Phoenix Intelligence" is GET /api/v1/phoenix/high-profile (REGISTERED — auth required).

Gating v2 (enable_cve_search_gating_v2, default off): when enabled, by-product requires an authenticated identity (401 otherwise), and trending, kev, homepage/intel, and kev-public/combined-exploited consume the search daily quota.

CVE Detail Enrichment (Purple contract B1–B3)

GET /api/v1/cves/{cve_id}
Note: Added 2026-07-25. Gated by feature flag enable_purple_enrichment_fields (SITE_CONFIG, default false). When off, the response is byte-identical to today's — these fields are simply absent, never present-but-null.

Five additive fields, each merged only if not already present, and only appearing once the caller's tier clears its minimum — a below-tier caller sees the field absent, never false/null standing in for “no data”:

FieldMinimum tierDescription
detail_urlall tiers (flag on)Canonical https://phxintel.security/cve.html?id=CVE-... deeplink.
root_causeall tiers (flag on){"class", "cwe_ids", "derivation"} — deterministic, CWE-derived, no LLM. See vocabulary below.
ransomwareREGISTEREDBoolean ransomware-campaign association.
ransomware_familiesPROstring[] named threat-actor families (from ransomware_db.json); empty array (not absent) when associated but no named family is known.
remediationREGISTEREDstring | null. Currently always null — no fix-version data source is wired into the CVE pipeline yet; this is a shipped placeholder field, not a working recommendation engine.

root_cause — 16-class vocabulary

class is one of: AUTHENTICATION, AUTHORIZATION, CRYPTOGRAPHIC, DESERIALIZATION, INFORMATION_DISCLOSURE, INJECTION, INPUT_VALIDATION, INSECURE_DEFAULT, MEMORY_SAFETY, PATH_TRAVERSAL, RACE_CONDITION, RESOURCE_EXHAUSTION, SSRF, SUPPLY_CHAIN_INTEGRITY, UNKNOWN, XSS (fixed taxonomy, web/data/root_cause_taxonomy.json).

derivationMeaning
cwe_primaryThe CVE's first-listed CWE mapped — the confident case.
cwe_secondaryA later CWE in the list mapped, not the first.
unmappedNo CWE mapped (or none present); class is UNKNOWN. Never a nearest-neighbour guess.

CVE → ATT&CK Technique Consensus — GET /api/v1/cves/{cve_id}/attack-techniques

Note: Added 2026-08-16 (B-6). Gated by feature flag enable_cve_attack_techniques (SETTINGS, default false). Returns 404 (never 403) when the flag is off — the feature's existence is never leaked. The flag stays false in every environment as of this writing. The consensus writer (Stage 1 supervised inference, Stage 3 LLM inference, and the attack_technique_enrichment pipeline lane) has since shipped, so once the flag is on and migration 126 is applied, this surface serves real data. Migration 126 is not yet applied in any environment as of this writing, and enablement additionally waits on two human decisions (a production LLM host, and hand-labelled precision measurement).

Surfaces a dual-method (supervised classifier + LLM) consensus mapping of a CVE to the MITRE ATT&CK techniques most likely used to exploit it. Only tier = 'corroborated' rows (both methods agreed) are ever exposed on any surface below; the suggested tier (single-method) is operator/PAI-only and is never built into either endpoint.

Tier-gated per field, on every surface. Once on, the block is reachable by whichever tier can already read the CVE detail endpoint, but its own fields are filtered per tier by data_shield (dataset attack_techniques) on all four surfaces — GET /{cve_id}, the dedicated forward endpoint, the reverse-lookup endpoint, and GraphQL. state, techniques, technique_id, technique_name, technique_tactics, technique_url, attack_technique_tier, attack_technique_claim_type and attack_technique_attribution (with its upstream_repo / upstream_doi / licence children — a CC-BY-4.0 obligation, never droppable) are public; attack_technique_sources requires Registered; attack_technique_supervised_score requires Pro; attack_technique_model_version, attack_technique_label_space_ver and attack_technique_attack_version are Internal (operator/admin). A field above your tier is omitted, not nulled.

Two separate routes, one per direction — never one route with a mode-switching query param. Forward (CVE → techniques) and reverse (technique → CVEs) return different response envelope shapes ({"cve_id", "attack_techniques"} vs. {"technique_id", "cves", "total"}), so they are deliberately two distinct, independently-documented endpoints rather than one route whose shape depends on an optional query string. This was an explicit fix during review.

Four surfaces (rule 78)

SurfaceBehaviour
GET /api/v1/cves/{cve_id}Additive attack_techniques key, null-safe merge. Absent (not null) when the flag is off, or when on but nothing has been computed yet.
GET /api/v1/cves/{cve_id}/attack-techniquesDedicated forward retrieval endpoint (this section).
GET /api/v1/attack-techniques/by-technique/{technique_id}Dedicated reverse retrieval endpoint (below).
GET /api/v1/sightings/{cve_id}/timelineDeliberately unchanged — no event emitted. computed_at is a pipeline run timestamp, not a vulnerability event; emitting it would pollute the timeline. Recorded as an explicit exemption in vulnerability_sightings.py::query_timeline and the feature-doc addendum.
GraphQL (CVE.attackTechniques)Additive field on the CVE type, same query layer and field names as REST (JSON scalar).
MCPDeliberately excluded this task. No tool/resource yet — revisit once Gate 3 clears.

Forward lookup

GET /api/v1/cves/{cve_id}/attack-techniques — strictly forward-only, no query parameters. Response: {"cve_id": "...", "attack_techniques": {...}}. When nothing has been computed for this CVE yet the attack_techniques key is omitted entirely — the response is just {"cve_id": "..."}, never "attack_techniques": null.

attack_techniques block shape

{
  "state": "corroborated",
  "techniques": [
    {
      "technique_id": "T1059",
      "technique_name": "Command and Scripting Interpreter",
      "technique_tactics": ["execution"],
      "technique_url": "https://attack.mitre.org/techniques/T1059",
      "attack_technique_tier": "corroborated",
      "attack_technique_claim_type": "exploitation_technique",
      "attack_technique_sources": ["supervised", "llm"],
      "attack_technique_supervised_score": 0.91,
      "attack_technique_model_version": "...",
      "attack_technique_label_space_ver": "...",
      "attack_technique_attack_version": "19.2"
    }
  ],
  "attack_technique_attribution": {
    "upstream_repo": "CIRCL/vulnerability-attack-technique-classification-roberta-base",
    "upstream_doi": "10.57967/hf/9623",
    "licence": "CC-BY-4.0"
  }
}
stateMeaning
corroboratedBoth methods agreed on at least one technique — techniques is non-empty.
silentBoth methods ran; nothing agreed. A real result, not an error.
below_quality_floorThe CVE description was too thin to attempt classification.
unadjudicatedThe LLM stage was never cost-eligible for this CVE.

technique_id is always the parent-level ATT&CK technique (never a sub-technique). A technique_id that does not resolve to a name in the ATT&CK catalog is dropped from the response, never emitted as a bare ID (Gate 6).

attack_technique_attribution is a static object, present whenever the block is present — a CC-BY-4.0 licence obligation, not decoration. Values are read live from pipeline/vuln_intel/models/cve_attack_technique_classifier.py's ATTRIBUTION object.

CVE → ATT&CK Technique Consensus, reverse lookup — GET /api/v1/attack-techniques/by-technique/{technique_id}

Corroborated CVEs mapped to {technique_id}, backed by idx_cve_attack_techniques_technique_tier (migration 126). A separate, standalone route — not a query-param mode on the CVE-scoped forward endpoint above. Same flag (enable_cve_attack_techniques), same 404-not-403 behaviour when off, same tier = 'corroborated'-only guarantee, and the same per-field tier gating described above — each cves entry carries the same PRO/INTERNAL field names as the forward endpoint's techniques entries and is filtered identically.

Returns 404 if {technique_id} does not resolve against the ATT&CK catalog — never a lookup keyed on an ID this surface cannot name. {technique_id} is case-insensitive: it is normalised to MITRE's canonical upper-case form before both the catalog lookup and the query, so t1059 and T1059 behave identically.

Query parameters:

ParameterTypeDefaultBoundsDescription
limitint1001–500 Maximum CVEs to return. Clamped in the service layer too, so a direct caller cannot request an unbounded page.
offsetint0≥ 0 CVEs to skip. Ordering is cve_id ASC, so paging is stable.

total is a real COUNT(*) over every corroborated row for the technique, independent of the page — matching GET /api/v1/cves/kev's {"total", "returned"} convention. returned is how many are actually in this response. A caller can therefore always tell a capped page from the complete answer: returned < total means more pages exist.

{
  "technique_id": "T1059",
  "technique_name": "Command and Scripting Interpreter",
  "technique_tactics": ["execution"],
  "technique_url": "https://attack.mitre.org/techniques/T1059",
  "total": 4213,
  "returned": 1,
  "limit": 100,
  "offset": 0,
  "cves": [
    {
      "cve_id": "CVE-2021-44228",
      "attack_technique_tier": "corroborated",
      "attack_technique_claim_type": "exploitation_technique",
      "attack_technique_sources": ["supervised", "llm"],
      "attack_technique_supervised_score": 0.91,
      "attack_technique_model_version": "...",
      "attack_technique_label_space_ver": "...",
      "attack_technique_attack_version": "19.2"
    }
  ],
  "attack_technique_attribution": {
    "upstream_repo": "CIRCL/vulnerability-attack-technique-classification-roberta-base",
    "upstream_doi": "10.57967/hf/9623",
    "licence": "CC-BY-4.0"
  }
}

Note the envelope is {"technique_id", "total", "returned", "limit", "offset", "cves", ...} — deliberately not {"cve_id", "attack_techniques"}, the forward endpoint's shape. Each entry in cves uses the same per-technique field names as the forward endpoint's techniques entries (minus technique_id/technique_name/technique_tactics/technique_url, hoisted to the top level since they describe the one technique being looked up, not each CVE).

POST /api/v1/cves/batch — Batch CVE Lookup (Purple contract B6)

Note: Added 2026-07-25. Gated by feature flag enable_cve_batch_lookup (SITE_CONFIG, default false). Returns 404 (not 403) when off.

Looks up many CVEs in one round trip. Every returned record passes through the exact same tier pipeline as GET /{cve_id}, so a batched record can never be richer than the same CVE fetched singly.

Request

{ "cve_ids": ["CVE-2024-27198", "CVE-2024-27199"] }

1–1000 strings. Input is uppercased, deduplicated, and sorted before lookup.

Response

{ "total": 2, "found": 2, "not_found": [], "items": [ { "cve_id": "CVE-2024-27198", "...": "same shape as GET /{cve_id}" } ] }

Per-tier request-size limit

Read live from the existing batch_lookup tier feature config, not a hardcoded constant:

Tierlimit_valueBehavior
Free0Disabled — any non-empty request → 413.
Registered50Up to 50 CVE IDs per request.
Pro500Up to 500 CVE IDs per request.
EnterpriseNoneUnlimited — the codebase's existing “no cap” sentinel, not a lookup-failure fallback.

This is a per-request size cap, not a daily call-count quota — there is currently no separate enforcement on how many batch calls a caller may make per day; each call only increments the batch_lookups usage-accounting field once. If the tier/config lookup itself fails (e.g. no database configured), the resolver fails safe to a conservative default of 50 — distinct from a successful lookup resolving to Enterprise's real None (unlimited).

Analysis Data

GET /api/v1/analysis/kev-public/combined-exploited

Returns the public combined exploited vulnerability catalog used by exploited-vulnerabilities.html. The backend reads web/data/kev_public/combined_exploited.json. Unavailable data returns 503.

CWE / OWASP Top 10 / Threat-Frequency Tiered Data

Note: Added 2026-07-10. Gated by feature flag ENABLE_CWE_OWASP_THREAT_TIERED_API (default false). When the flag is off, all five endpoints below behave exactly as they did before this feature — fully open, no tier shaping — so this is a safe no-op until enabled. This is a payload-shaping feature, not a hard gate: none of these endpoints return 403 for tier reasons, and every response is HTTP 200.
MethodPathSource dataset
GET/api/v1/analysis/cwecwe_analysis.json
GET/api/v1/analysis/cwe-top25cwe_top25_analysis.json
GET/api/v1/analysis/kev-cwekev_cwe_analysis.json
GET/api/v1/analysis/owasp-top10owasp_top10_analysis.json
GET/api/v1/analysis/reference-threat-frequenciessee note below — the data source itself is flag-gated, not just the shaping

Each has a full-fidelity PAI mirror under /internal/v1/analysis/* — see PAI_INTERNAL_API.html.

Tier behavior (flag ON)

CallerPayload
Anonymous / Free / Registered / Pro (JWT or API key) Shaped: CWE/OWASP category mappings, names, rankings, and MITRE links are unchanged (public reference data). Proprietary numeric fields — raw NVD/H1/KEV/VulnCheck/GitHub PoC counts, per-CWE and per-category statistics, threat_mapping counts, and yearly-trend raw numbers — are converted to percentage shares only. The raw counts and the totals used to derive a percentage are stripped together, so a caller cannot recompute the original count from the shaped response.
Enterprise (JWT) or global admin Full numeric payload: raw counts, baseline_ratio, cross_dataset_analysis, and full yearly trends.
PAI (X-PAI-Key, /internal/v1/analysis/*) Always full numeric payload — PAI access is itself the Enterprise gate; no additional tier parameter or header is needed.

Because there is no 403 on these base routes, the existing public pages that already call them (owasp-top10-analysis.html, cwe-top25-analysis.html, cwe.html, kev-cwe-analysis.html, threat-analytics.html, index.html) continue to work unmodified at every tier — an Enterprise upgrade only changes response richness, not endpoint availability.

Note: reference-threat-frequencies has its own, stricter per-tier exposure model (see below) — it is not governed by the table above.

reference-threat-frequencies — data source is also flag-gated, own per-tier exposure model

Unlike the other four endpoints (which only gate the shaping step against an always-available source file), /api/v1/analysis/reference-threat-frequencies also switches its underlying data source on the same flag:

Tier access matrix
CallerExposurepercentage field
Anonymous Nonethreat_type/threat_impact distributions and frequencies are omitted entirely (structure/metadata still returned). Not present.
Free None — same as Anonymous. Not present.
Registered None — same as Anonymous. Not present.
Pro Bands only — qualitative frequency_band/vs_nvd_baseline (high/medium/low) per entry; no count, no percentage. Not present.
Enterprise (JWT) or global admin Percentages onlypercentage per entry (recomputed from count if absent); count, baseline_ratio, and cross_dataset_analysis are stripped/emptied. Present — Enterprise-only. No lower tier ever receives this field.
PAI (X-PAI-Key, /internal/v1/analysis/reference-threat-frequencies) Full — pure passthrough of the raw data: count, percentage, baseline_ratio, and cross_dataset_analysis all present together. This is the only way to get raw counts for this dataset (never exposed on the public API, at any tier). Present, alongside raw count — not exclusive to Enterprise; PAI is a separate internal plane (/internal/v1/*), not a public-API tier.

For every distribution in this dataset (the baseline threat-type/threat-impact distributions, and each per-dataset threat_type_frequencies/threat_impact_frequencies), no caller below Enterprise on the public API ever receives raw counts, and no caller below Pro receives any threat-type/impact numbers or bands at all.

Upgrade path

Free, Registered, and Anonymous callers see no threat_type/threat_impact data at all on this endpoint — upgrade to Pro to see qualitative frequency_band/vs_nvd_baseline bands (high/medium/low, no numbers). Pro callers see bands only — upgrade to Enterprise to see the numeric percentage field per entry (still no raw count). Raw count values are never returned on the public API at any tier — that data is PAI//internal/v1-only (see PAI_INTERNAL_API.html), reserved for internal/partner integrations, not a self-service public-API upgrade.

Example responses per tier

Each example shows the same underlying baseline.threat_type_distribution / baseline.threat_impact_distribution entry as it appears at each tier (trimmed to the relevant fields):

Anonymous / Free / Registered (AccessMode.NONE) — no distributions at all:

{
  "generated_at": "2026-07-27T00:00:00Z",
  "baseline": {
    "total_cves": 500
  },
  "cross_dataset_analysis": {}
}

Pro (AccessMode.BANDS) — qualitative bands only:

{
  "generated_at": "2026-07-27T00:00:00Z",
  "baseline": {
    "threat_type_distribution": {
      "Authentication Misuse": { "frequency_band": "medium", "vs_nvd_baseline": "low" }
    },
    "threat_impact_distribution": {
      "Missing/Broken Access Control": { "frequency_band": "high", "vs_nvd_baseline": "high" }
    }
  },
  "cross_dataset_analysis": {}
}

Enterprise / global admin (AccessMode.PERCENT_ONLY) — percentage is Enterprise-only:

{
  "generated_at": "2026-07-27T00:00:00Z",
  "baseline": {
    "threat_type_distribution": {
      "Authentication Misuse": { "percentage": 8.0 }
    },
    "threat_impact_distribution": {
      "Missing/Broken Access Control": { "percentage": 15.0 }
    }
  },
  "cross_dataset_analysis": {}
}

PAI (X-PAI-Key, /internal/v1/analysis/reference-threat-frequencies, AccessMode.FULL) — raw counts, PAI-only, never on the public route:

{
  "generated_at": "2026-07-27T00:00:00Z",
  "baseline": {
    "threat_type_distribution": {
      "Authentication Misuse": { "count": 40, "percentage": 8.0, "baseline_ratio": 0.6 }
    },
    "threat_impact_distribution": {
      "Missing/Broken Access Control": { "count": 75, "percentage": 15.0, "baseline_ratio": 1.6 }
    }
  },
  "cross_dataset_analysis": { "threat_impact_risk_ranking": ["Missing/Broken Access Control"] }
}

Merged CWE-Intelligence Table — GET /api/v1/analysis/cwe-intelligence

Added 2026-08-23. Gated by feature flag enable_cwe_intelligence_endpoint (default false). Unlike the five CWE/OWASP/threat-frequency routes above (whose flag only governs payload shaping and which always return 200), this route is new and has no legacy unflagged behavior to preserve, so the flag gates the route itself: it returns 404 (not 403) on both this public route and its PAI mirror when off.

One CWE-keyed table replacing the client-side join that four separate round trips (/cwe, /kev-cwe, /owasp-top10, plus the static CWE_MASTER reference data) otherwise imply: an outer join over all four sources, keyed by bare CWE id (e.g. "79", not "CWE-79").

Has a full-fidelity PAI mirror at GET /internal/v1/analysis/cwe-intelligence — see PAI_INTERNAL_API.html.

Tier resolution is NOT the shared resolve_access_mode

This is the one deliberate, security-relevant departure from the four sibling routes above. Those routes resolve their AccessMode via the shared resolve_access_mode dependency, which grants AccessMode.FULL (raw counts) to Enterprise callers. Reusing that dependency here would leak raw nvd_count/kev_count to Enterprise on the public plane — data this endpoint reserves for PAI only. /cwe-intelligence therefore resolves tier through its own dedicated dependency, resolve_cwe_intelligence_exposure:

CallerAccessModeFields present per CWE
Anonymous / Free / Registered (JWT or API key) NONE Non-numeric reference fields only: name, threat_class, impact, owasp_2017, owasp_2021, owasp_2025, cwe_top25_rank, and a filtered sources list. No frequency information at all.
Pro BANDS Adds qualitative frequency_band (high/medium/low, same thresholds as the sibling routes) — no percentage, no raw counts. Omitted when the CWE has no NVD count to derive a band from.
Enterprise (JWT) or global admin PERCENT_ONLY Adds numeric percentage (nvd_count as a share of all classified CVEs, 2dp) — never raw nvd_count, and never a KEV-derived percentage. Omitted when there is nothing to derive it from.
PAI (X-PAI-Key, /internal/v1/analysis/cwe-intelligence) FULL Adds raw nvd_count and raw kev_count. The only plane where either raw count is ever returned.

kev_count gets identical treatment to nvd_count at every tier below FULL: it is never derived into a percentage or a band for a lower tier, since inventing exposure on a tier-enforcement surface is a security bug, not a style choice. sources is also tier-filtered below FULL: entries "cwe_analysis" and "kev_cwe" are stripped because their mere presence would disclose "this CWE has an NVD/KEV count" even with the count itself correctly absent; "owasp" and "cwe_master" are never stripped, since their presence is already fully inferable from the always-present reference fields.

Same CWE row across tiers

Each example below is CWE-79 (Cross-site Scripting) as it appears at each tier, i.e. the value of cwes["79"]:

Anonymous / Free / Registered (AccessMode.NONE) — no frequency information at all:

{
  "name": "Cross-site Scripting",
  "threat_class": "Sensitive Information Disclosure",
  "impact": "Cross-Site Scripting (XSS)",
  "owasp_2017": "A7 – XSS",
  "owasp_2021": "A03:2021",
  "owasp_2025": "A05:2025",
  "cwe_top25_rank": 1,
  "sources": ["owasp", "cwe_master"]
}

Pro (AccessMode.BANDS) — adds a qualitative band, no numbers:

{
  "name": "Cross-site Scripting",
  "threat_class": "Sensitive Information Disclosure",
  "impact": "Cross-Site Scripting (XSS)",
  "owasp_2017": "A7 – XSS",
  "owasp_2021": "A03:2021",
  "owasp_2025": "A05:2025",
  "cwe_top25_rank": 1,
  "sources": ["owasp", "cwe_master"],
  "frequency_band": "high"
}

Enterprise (JWT) or global admin (AccessMode.PERCENT_ONLY) — adds a computed percentage, never the raw count it is derived from:

{
  "name": "Cross-site Scripting",
  "threat_class": "Sensitive Information Disclosure",
  "impact": "Cross-Site Scripting (XSS)",
  "owasp_2017": "A7 – XSS",
  "owasp_2021": "A03:2021",
  "owasp_2025": "A05:2025",
  "cwe_top25_rank": 1,
  "sources": ["owasp", "cwe_master"],
  "percentage": 20.88
}

PAI (X-PAI-Key, GET /internal/v1/analysis/cwe-intelligence, AccessMode.FULL) — the only plane where either raw count is ever returned:

{
  "name": "Cross-site Scripting",
  "threat_class": "Sensitive Information Disclosure",
  "impact": "Cross-Site Scripting (XSS)",
  "owasp_2017": "A7 – XSS",
  "owasp_2021": "A03:2021",
  "owasp_2025": "A05:2025",
  "cwe_top25_rank": 1,
  "sources": ["cwe_analysis", "kev_cwe", "owasp", "cwe_master"],
  "nvd_count": 50707,
  "kev_count": 12
}

Upgrade path: Free/Registered/anonymous see the reference fields only, with no frequency signal at all. Pro adds frequency_band (qualitative, no numbers). Enterprise adds percentage (a computed share of classified CVEs — still never the raw nvd_count it is derived from). Raw nvd_count and kev_count are available only via the PAI mirror (GET /internal/v1/analysis/cwe-intelligence) — no public tier returns either raw count.

Response envelope and caching

{
  "schema_version": "cwe-intel-v1",
  "source_provenance": [
    { "name": "cwe_analysis", "generated_at": "2026-08-20T00:00:00Z" },
    { "name": "kev_cwe_analysis", "generated_at": "2026-08-19T00:00:00Z" },
    { "name": "owasp_top10_analysis", "generated_at": "2026-08-18T00:00:00Z" },
    { "name": "cwe_master", "generated_at": null }
  ],
  "cwes": {
    "79": { "name": "Improper Neutralization of Input During Web Page Generation", "...": "..." }
  },
  "total_unique_cwes": 1,
  "dropped_keys": 0
}

detail_url on GET /api/v1/analysis/cwe (Purple contract B1)

Note: Added 2026-07-25. Gated by enable_purple_enrichment_fields (default false). Runs after tier shaping, so PERCENT_ONLY/FULL numeric behavior is unchanged.

Each entry in top_cwes/top_cwes_limited gets a detail_url pointing at MITRE's own CWE definition page (https://cwe.mitre.org/data/definitions/{n}.html) — a deliberately different host than the CVE/package detail_url fields, because cwe.html has no same-origin per-CWE detail view.

GET /api/v1/analysis/cwe/{cwe_id}/exploited-cves — CWE-to-Exploited-CVE Reverse Index (Purple contract B7)

Note: Added 2026-07-25. Gated by feature flag enable_cwe_exploited_cves (SITE_CONFIG, default false). Returns 404 when off.

An unknown or never-exploited CWE returns an empty list with total: 0, not 404. Built by a live join — the full KEV membership set (CVEDataService.get_kev_data(), every KEV-listed CVE with no year restriction) crossed against each CVE's own cwes list (neither existing data file has both CWE and CVE IDs together) — built once against the shared, process-wide CVEDataService singleton off the request event loop (run_in_threadpool), cached at process scope, not rebuilt per request. (An earlier version joined via CVEDataService.search_cves(kev_only=True), which only scans the trailing 5 calendar years by default and silently dropped older KEV CVEs such as Log4Shell/CVE-2021-44228; fixed 2026-07-25.)

{ "cwe_id": "CWE-89", "detail_url": "https://cwe.mitre.org/data/definitions/89.html", "total": 12,
  "items": [ { "cve_id": "CVE-2023-...", "severity": "CRITICAL", "kev_added_at": null, "detail_url": "..." } ] }

kev_added_at is currently always null: the only KEV membership source wired in is a bare membership set with no per-CVE date, so the field never has a value today — a disclosed current data-source limitation, not a bug. Query parameter: limit (1–500, default 50).

Premium Package Intelligence

POST /api/v1/packages/intel

Returns package-version CVE intelligence plus premium MPI malware intelligence for Pro and Enterprise Intelligence API keys.

Gated by feature flag enable_package_intel_api (default false). When the flag is off the endpoint returns 404 (not 403). Registered/basic keys return 403.

Successful calls are counted as REST API calls and as mpi_package_intel_lookups in MPI usage accounting.

Request

{
  "ecosystem": "Maven",
  "name": "com.h2database:h2",
  "version": "1.3.176"
}

Response fields

FieldNotes
cvesVersion-filtered OSV CVE/advisory records.
vulnerabilitiesAlias of cves.
malwarePremium MPI malware summary. Raw signals, IOCs, scan IDs, and internal evidence are not exposed on the public plane.
ps_oss_scoreDerived package risk score.
source_attributionSources used to build the response.

Package malware history rollup (Purple contract B4)

Note: Added 2026-07-25. Gated by enable_purple_enrichment_fields (default false), in addition to enable_package_intel_api above.

Built by the same build_malware_history() function GET /api/v1/malware/{purl} uses (below), so the two surfaces cannot drift:

FieldNotes
ever_maliciousBoolean; true only if any version's public verdict is MALICIOUS. SUSPECT never sets this.
first_flagged_atEarliest observed_at among MALICIOUS/SUSPECT versions, or null.
version_verdicts{version, verdict, signal_change, observed_at} per known version. signal_change is a short stable sentence, never the raw diff.
campaigns{name, active, first_seen} — named campaigns linked to this package. On this route every caller already holds a Pro/Enterprise key, so campaign data is always included when available.
detail_urlThe package's own malware.html?purl= deeplink (B1).

EU CRA / SBOM / VEX Library Intelligence

POST /api/v1/intel/library-versions
Note: Added 2026-08-17 (branch feat/library-enhancement-api, not yet merged to main). Gated by feature flag enable_cra_intel_surface (SITE_CONFIG, default false, absent from site_config.json). When the flag is off the endpoint returns 404 (not 403) so the feature's existence is not leaked in the response. Caveat, so the 404 is not read as stronger concealment than it is: the router is mounted unconditionally (backend/app/main.py) and /api/v1/intel is in the public OpenAPI allow-list _PUBLIC_API_PREFIXES, so the route is visible in the generated schema at /api/docs even while every call returns 404. The 404 withholds the response, not the route's existence. This matches the pre-existing enable_intel_query_surface mounted on the same prefix — the established pattern here, not something specific to this endpoint. See the feature doc for the full design rationale, evidence register, and known gaps.

Capped batch (purls[], max 500) EU CRA (Regulation (EU) 2024/2847) / SBOM / VEX evidence composition for a downstream consumer (e.g. an ASPM platform) that generates the actual compliance documentation. Blue emits evidence only — it never emits a VEX analysis.state. VEX status (affected / not_affected / fixed) depends on reachability and runtime context that exists only in the consumer's application graph; Blue supplies dated, citable evidence rows the consumer attaches to its own verdict.

Auth

Required header: X-API-Key: phx_api_*. Requires a Pro or Enterprise premium intelligence key for the whole endpoint — the same bar /packages/intel above requires (key scope API_POWER, API_INTEGRATION, or API_UNLIMITED, resolving to a Pro/Enterprise tier). A Registered/api_basic key returns 403; no key at all returns 401. Both endpoints share one check (require_premium_intelligence_key() in backend/app/services/premium_intelligence_auth.py) so they cannot drift apart. Tier-based field shaping (exploitation_tier, cra_inputs, etc.) still applies on top of that gate — see Tier access below for exactly what that does and does not change. The malware block on each component is gated separately — see the note below — and is absent from the component (never null) when not licensed. Successful calls are counted as REST API calls and as batch_lookups in usage accounting — the same existing counter POST /api/v1/cves/batch uses (one increment per batch request, not per purl inside it). An earlier revision named a cra_library_intel_lookups counter that has no user_usage column and was silently discarded by the storage allow-list, so nothing was counted.

Note: the malware block's gate checks the caller's global_intel product entitlement (via tier_resolver.resolve_intel_tier, gated on enable_global_intel_license), not just their Pro/Enterprise tier — a Pro/Enterprise key clears the endpoint's own access gate but does not by itself grant the malware block. Since enable_global_intel_license is absent from site_config.json as of this writing, malware is absent from every response regardless of caller until that flag is enabled.

Tier access

The endpoint gate is all-or-nothing: the two lower tiers never receive a body, so there is no Free/Registered response example to give.

CallerResultBody
No API key401{"detail": "CRA library intelligence requires an API key"}
Free / anonymous key403{"detail": "CRA library intelligence requires a Pro or Enterprise intelligence key"}
Registered (api_basic)403Same gate; the exact detail may come from the shared scope validator rather than this endpoint's own string.
Pro (API_POWER / API_INTEGRATION)200Full response as documented below.
Enterprise (API_UNLIMITED)200Byte-identical field set to Pro — see below.
Flag enable_cra_intel_surface off (any caller)404{"detail": "Not Found"} — checked before auth, so a 404 is returned even without a key.

No field on this endpoint differs between Pro and Enterprise today. Every CRA field is classified PUBLIC, REGISTERED or PRO in data_shield.DEFAULT_FIELD_CLASSIFICATIONS; none is classified ENTERPRISE. Filtering the same component at both tiers yields identical key sets. This is stated explicitly rather than left implied: there is no Enterprise-only field to look for, and any future one must be added here at the same time it is classified.

Tier shaping is still applied (filter_response_for_tier(..., "cra_intel")) as defence in depth — it is what would withhold the PRO fields (exploitation_tier, predicted_exploitation, cra_potentially_exploited, cra_inputs, exploitation_evidence_rows) from a Registered caller, and the REGISTERED fields (cra_actively_exploited, first_verified_exploited, ransomware, safe_upgrade_version) from a Free caller, if either could ever reach the handler. Neither can, through this endpoint. cra_safe_upgrade_corroboration — the three-state caveat on safe_upgrade_version described below — is PUBLIC and is never withheld, even from a Free caller who cannot see safe_upgrade_version itself. The same split applies to the reference-release fields: cra_safe_upgrade_reference is a version number and an upgrade target, so it is REGISTERED like safe_upgrade_version, while cra_safe_upgrade_reference_state and cra_safe_upgrade_reference_as_of are PUBLIC — a caller below REGISTERED still learns a reference exists and was withheld by tier, rather than being unable to tell that from "no reference exists".

The malware block is the one part of the response gated by something other than tier: it requires the global_intel product entitlement on the caller's org (a purchased bundle, not a plan tier), and that check is itself behind the enable_global_intel_license flag. A Pro or Enterprise key with no such entitlement gets every other field and no malware key at all (absent, never null and never {} — an empty object would read as "nothing found", which is the wrong claim for malware). As of this writing enable_global_intel_license is absent from site_config.json, so malware is absent from every response regardless of caller.

Upgrade path

FromTo getDo
No key401 → 403Register and issue an API key (/api-keys).
Free / Registered200 with the full CRA bodyMove to a Pro or Enterprise plan and issue a key with scope API_POWER, API_INTEGRATION or API_UNLIMITED. An existing api_basic key is not upgraded in place — issue a new key under the new plan.
ProNothing further for CRA fieldsEnterprise adds no CRA field today (see above). Choose it for quota and support, not for this endpoint's shape.
Pro / Enterprise without malwareThe malware blockPurchase the global_intel bundle for the org. The org admin's keys are re-stamped with the entitlement; the platform flag enable_global_intel_license must also be on.

Request

{
  "purls": [
    "pkg:npm/lodash@4.17.20",
    "not-a-purl"
  ]
}

purls requires 1–500 entries; each purl string is additionally capped at 512 characters.

Response

{
  "rule_version": "cra-v1",
  "as_of": {
    "kev": "2026-06-13T20:39:02Z",
    "shadowserver": "2026-02-19",
    "library_intel": "2026-04-08T00:00:00Z"
  },
  "components": [
    {
      "purl": "pkg:npm/lodash@4.17.20",
      "ecosystem": "npm",
      "name": "lodash",
      "version": "4.17.20",
      "component_resolved": true,
      "safe_upgrade_version": "4.17.21",
      "cra_safe_upgrade_corroboration": "corroborated",
      "cra_vulnerability_total": 3,
      "cra_vulnerabilities_truncated": false,
      "vulnerabilities": [
        {
          "cve_id": "CVE-2021-23337",
          "cvss": 7.2,
          "epss": 0.42,
          "fixed_version": "4.17.21",
          "affected_versions": ["< 4.17.21"],
          "rule_version": "cra-v1",
          "exploitation_tier": "ACTIVELY_EXPLOITED",
          "predicted_exploitation": "POTENTIALLY_EXPLOITED",
          "ransomware": true,
          "cra_actively_exploited": true,
          "cra_potentially_exploited": true,
          "cra_version_match_undetermined": false,
          "cra_inputs": ["cisa_kev", "epss", "shadowserver", "vulncheck_kev"],
          "first_verified_exploited": "2021-03-01",
          "exploitation_evidence_rows": [
            {
              "source": "cisa_kev",
              "date_added": "2021-03-01",
              "description": "...",
              "cra_known_ransomware_campaign_use": "Known",
              "url": null
            },
            {
              "source": "shadowserver",
              "first_seen": "2026-02-05",
              "honeypot_counts": {"1d": 431, "7d": 324, "30d": 344, "90d": 401},
              "url": "https://example/x"
            }
          ]
        }
      ],
      "malware": { }
    }
  ],
  "unresolved": [
    {"purl": "not-a-purl", "reason": "malformed_purl"}
  ]
}

Contract invariants

InvariantMeaning
component_resolved always presentAn unresolvable purl goes to unresolved with a reason — never a clean, empty component. Three reasons exist and are not interchangeable: malformed_purl (did not parse), library_intel_record_not_found (a genuine miss), and resolution_error (the lookup itself failed — we could not find out). No internal error detail reaches the response. "No CVEs found", "no data for this purl" and "our lookup crashed" are different legal claims.
Every emitted CVE is in scope for the submitted versionThe library-intelligence record is keyed by purl base (version stripped), so its CVE list is package-wide. Each row is evaluated against the submitted version’s affected_versions/fixed_version before it is emitted; a CVE the version does not fall in is omitted and feeds neither cra_actively_exploited nor safe_upgrade_version. When the comparison cannot be made (unversioned purl, unparseable version, unintelligible range) the CVE is KEPT and marked cra_version_match_undetermined: true — dropping on ambiguity would under-report.
cra_vulnerability_total / cra_vulnerabilities_truncated disclose the producer caplibrary_intelligence.json persists only the top-ranked CVE rows per package (ranked by exploitation tier, then EPSS, then CVSS, so evidence survives the cut ahead of raw severity). cra_vulnerability_total is the real count; when cra_vulnerabilities_truncated is true the emitted list is a partial view and safe_upgrade_version is blank.
safe_upgrade_version never "latest"Lowest version clearing every CVE in the emitted list, rolled up from each vulnerability’s fixed_version. Blue does not consult a package registry, so a component with zero known CVEs returns a blank — never a claim of currency. Also blank whenever cra_vulnerabilities_truncated is true: a maximum over a partial list would name a version that does not clear the omitted CVEs.
cra_safe_upgrade_corroboration caveats safe_upgrade_version with a THREE-state string, never absent when it is presentsafe_upgrade_version is rolled up purely from advisory fixed_version values, never a registry, so the advisory data itself can name a version the package never shipped — e.g. lodash's own advisories assert 4.18.0 (twice) and 4.17.23 against a real latest release of 4.17.21. Three states: "corroborated" (a parseable latest_version backs up the chosen version); "exceeds_known_latest" (a parseable latest_version exists and the chosen version is above it — the lodash/axios/electron case; this deliberately does NOT assert "phantom", since a fabricated advisory version and a genuinely newer real release our snapshot hasn't caught up to look identical from here); and "no_reference_version" (no usable latest_version on either side — nothing was checked). About 92% of components that emit a safe_upgrade_version land in "no_reference_version", so its absence is NOT evidence any comparison passed — an earlier boolean revision of this field collapsed "corroborated" and "never checked" into the same false, a stronger false statement than the bare version was. A disclosure, not a filter: latest_version is too unreliable a snapshot (~10% of packages with both fields have a legitimately newer real fix above a stale latest_version) to use as a suppression ceiling, but it is the right signal to flag an unsubstantiated upgrade target.
cra_safe_upgrade_reference resolves the "latest" directive, or says why it could notThree keys, always present on every component (never omitted — an absent key and a null read differently, and the state field carries the reason). cra_safe_upgrade_reference is the resolved release, REGISTERED, non-null only in the "registry_latest_stable" state. cra_safe_upgrade_reference_state is PUBLIC and is one of: "registry_latest_stable" (a registry-sourced release strictly above the submitted version — the only state carrying a version number); "not_above_submitted" (a registry-sourced release exists but is at or below the submitted version, or an operand would not parse — the version is withheld); "no_registry_reference" (no registry-sourced release; not the same as "no latest_version" — a deps.dev-sourced value lands here on purpose, since only latest_stable carries registry provenance; 85% of "no_known_fix" components are here today); "not_applicable" (the upgrade target is a real version, so there is no directive to resolve). cra_safe_upgrade_reference_as_of is PUBLIC — the library-intelligence artifact's own _metadata.generated_at, attached per component so a component read in isolation carries its own age; null if unavailable, never an omitted key, because a reference with no date would read as current.
cra_actively_exploited is evidence-onlyA high EPSS score never sets it; it tracks exploitation_tier alone. cra_potentially_exploited tracks predicted_exploitation alone. The two never influence each other.
predicted_exploitation is UNKNOWN, not NONE, when EPSS is unavailableAbsence of data must never render as absence of risk.
malware absent, not nullPresent only for callers licensed for the premium block.
Partial failure is per-componentOne malformed or unresolvable purl never fails the rest of the batch.

Known limitations

Evidence is read from a process-cached singleton, so the corrected exploitation_tier mapping applies as of the last backend process restart, not continuously as feeds change; the precomputed library_intelligence.json artifact itself has not been regenerated on the branch that introduced this endpoint. No live end-to-end smoke test against a running backend has been performed for this endpoint as of this writing. The design spec's second endpoint (extending GET /api/v1/intel/library_version/{purl}) has not been implemented. See the feature doc for full detail.

Unified Intelligence Query Surface

GET /api/v1/intel/*
Note: Added 2026-07-25. Gated by feature flag enable_intel_query_surface (SITE_CONFIG, default false). When the flag is off, every route below returns 404 (not 403).

A single, uniform query surface over eight already-shipped entity resolvers (CVE, CWE, vendor, product, library, library version, malware, CPE). One resolver registry (backend/app/services/intel_query/registry.py) backs three other adapters querying the exact same resolvers — PAI (/internal/v1/intel/*, see PAI_INTERNAL_API.html), MCP tools (intel_get/intel_search/intel_vocabularies, see MCP_API_INTEGRATION_GUIDE.html), and the phoenix-cli intel command group — so the four surfaces cannot drift on what an entity contains, only on how the response is tier-shaped.

MethodPathPurpose
GET/api/v1/intel/entitiesList the 8 queryable entity names.
GET/api/v1/intel/vocabulariesPublished enumerated class vocabularies and cutpoints.
GET/api/v1/intel/{entity}Search one entity (q, vendor, product, purl, limit 1–200 default 20, offset).
GET/api/v1/intel/{entity}/{entity_id}Get one entity by ID (entity_id accepts embedded /).
EntityID form example
cveCVE-2024-27198
cweCWE-89
vendormicrosoft
productmicrosoft:windows (vendor:product)
librarypkg:npm/express
library_versionpkg:npm/express@4.17.1
malwarepkg:npm/express@4.17.1 (same purl form)
cpecpe:2.3:a:microsoft:windows:...

Envelope

Every get/search result item — on every one of the four surfaces — is the same shape:

{
  "schema_version": "1.0.0",
  "entity": "cve",
  "id": "CVE-2024-27198",
  "deterministic": { "identity": {...}, "scoring": {...}, "classification": {...}, "exploitation": {...} },
  "advisory": null,
  "provenance": {
    "sources": ["cisa-kev", "nvd"],
    "evaluated_at": "2026-07-25T00:00:00Z",
    "vocabularies": { "signal_taxonomy": "4.0", "mitre_attack": "Enterprise v16" },
    "redaction_profile": "registered"
  }
}

Confidence class (published cutpoints)

ClassRange
VERY_HIGH>= 0.90
HIGH0.75–0.8999
MODERATE0.50–0.7499
LOW0.25–0.4999
VERY_LOW< 0.25
UNKNOWNnot available

confidence_class() (backend/app/services/intel_query/vocab.py) accepts either a 0-1 fraction or a 0-100 integer percent and normalizes internally — any value > 1.0 is treated as a percent and divided by 100 before the cutpoints above apply, so callers never need to pre-scale. This matters in practice: the only real production writer of LLM analyst confidence, pipeline/mpi_worker.py, stores a raw 0-100 integer per the LLM's own response schema, never a 0-1 fraction. An earlier version of confidence_class() had no such normalization and silently misclassified almost every real confidence value as VERY_HIGH; this was found and fixed during the Enterprise MPI reasoning plan's implementation — see the feature doc for the full history.

Severity class: CRITICAL, HIGH, MODERATE, LOW, NONE, UNKNOWN (NVD/CVSS MEDIUM maps to MODERATE). GET /api/v1/intel/vocabularies also publishes the 12 MPI signal categories, the signal-taxonomy version (currently "4.0"), and the MITRE ATT&CK version ("Enterprise v16").

Tier matrix (category level)

Field-level shaping runs through the existing data_shield.filter_response_for_tier pass via roughly 52 new field-name classifications. That map is flat and global (keyed by field name, not by envelope), so this is a category summary, not a field-by-field list:

Data categoryMinimum tier
Envelope wrapper (schema_version, entity, id, provenance, the deterministic container)Public
CVE core (CVE ID, dates, CVSS score/vector, severity, CWE ID, KEV membership/date)Public
Malware verdict class (class: MALICIOUS/SUSPECT/CLEAN), scan/purl identityPublic
CPE / vendor / product mapping fieldsRegistered
PS-OSS bandPro
MITRE tactics (coarse aggregate)Pro
MPI signal IDs, MITRE technique IDs (fine-grained), signal chainsEnterprise
IOC type listsPro
Raw per-CWE KEV counts (volumes, not IDs/ranks/labels)Enterprise

IOC values are never emitted at any tier — only IOC types. mitre.tactics (Pro) and mitre.technique_ids (Enterprise) share the parent mitre container: the container key is checked first, then each child key is independently re-checked, so a Pro caller sees tactics while technique_ids stays empty for them. Below-tier callers see the gated block present but empty (e.g. "mitre": {}) rather than omitted — this is the correct shape, not a bug.

Malware verdict access gate (allow_clean)

A separate mechanism from the tier matrix above. Whether a caller can see an unconfirmed/under-review (SUSPECT-band) malware package at all is controlled by an allow_clean parameter threaded through the resolver registry to the malware resolver. On this public surface: allow_clean = is_global_admin — a non-admin caller, at any tier including Enterprise, never resolves a SUSPECT-band package; only confirmed-MALICIOUS (or CLEAN) packages are visible, matching backend/app/routers/malware_public.py. Field shaping only limits which fields of an already-visible response are shown; it cannot recreate a 404 for a package that should not be visible at all — the two mechanisms are independent. See PAI_INTERNAL_API.html for the PAI mirror's (stricter, non-bypassing) policy.

Advisory block opt-in — ?include=advisory (Plan D)

Note: Added 2026-07-26. Requires both enable_intel_query_surface and enable_mpi_reasoning_enterprise to be on; advisory stays null if either is off.

The malware entity's advisory block can be populated via an optional include query parameter on both GET /api/v1/intel/{entity} (search) and GET /api/v1/intel/{entity}/{entity_id} (get):

GET /api/v1/intel/malware/pkg:npm/evil-pkg@1.0.0?include=advisory

Expanded intelligence composition — ?include= (Plan B + Plan D)

Added on this public surface 2026-08-14 (Plan D). Requires enable_intel_query_surface and enable_global_intel_license, both default false. Shipped first on the PAI mirror in Plan B — see PAI_INTERNAL_API.html for the composition internals, provenance semantics and block-isolation behaviour, which are identical on both transports.

GET /api/v1/intel/library_version/pkg:npm/left-pad@1.3.0?include=vulnerabilities,malware,exploitation,campaign,licensing
GET /api/v1/intel/library/pkg:npm/left-pad?include=all

Returns the entity, its vulnerabilities with per-CVE exploitation detail, its malware intelligence, and its exploitation / campaign / licensing vectors in one call, under a new top-level expanded envelope block. SCHEMA_VERSION is 1.1.0.

A request with no include is unchanged, byte for byte. The expanded key is absent, not null; provenance keeps exactly its four original keys; no licence lookup, no quota consumption and no composition happen at all. ?include=advisory alone is likewise unaffected — it needs no bundle.

Two independent axes decide what you get back. Confusing them is the single most common misreading of this surface:

AxisDecided byControls
Global Intel bundle (pro / enterprise, or none) a global_intel product entitlement, or a global_intel_tier stamp on the API key which blocks compose at all, and the per-tier vulnerability cap (Pro 100, Enterprise 500)
Platform tier (free / registered / pro / enterprise) the normal caller-tier resolution every other public route uses which fields survive data_shield inside those blocks

An Enterprise bundle held by a Free-tier caller therefore composes every block and then has almost all of it shaped away. That is correct.

Which credential carries the bundle (corrected 2026-08-14). On an API-key call the licence is read from the presented key only when that key's scope is api_global_intel (phx_gintel_). A grant stamps global_intel_tier on every key the holder owns, but only the api_global_intel scope lifts the caller's platform tier — so reading the licence from any stamped key meant a bundle holder calling with their ordinary phx_api_ key was charged a licence-gated intel_expanded call and then shaped at registered: billed for one tier, served another. Calling with a non-bundle key now returns the ordinary 403 (licence required) and is charged nothing. Use the phx_gintel_ key for expanded calls. Session (JWT) callers resolve the licence from their global_intel entitlement row as before.

Tokens. vulnerabilities, malware, exploitation, campaign, licensing, plus advisory (licence-free, documented above) and all. all expands server-side to every token the caller's bundle permits and never errors on an over-privileged token. Token validation and the token→tier matrix live in backend/app/services/intel_query/registry.py — one gate for all four transports (REST, PAI, MCP, CLI), not one per adapter.

Field-level tier matrix (data_shield). Cumulative — each tier sees its own column plus every column to its left.

BlockFreeRegisteredProEnterprise
vulnerabilities items[] (cve_id, severity, summary, fixed_version, exploitation.{kev_listed,epss}), total, returned, truncated + items[].osv_id, items[].affected_versions + items[].exploitation.poc_count
licensing(block hidden) spdx_id, category, license_risk_score, license_policy
exploitation(present but empty — see note) (present but empty) exploitation_tier, flags, block_counts + tte.{tte_hours,speed_pressure,ecosystem_cohort}
campaign(block hidden)(block hidden) compromised, campaigns[].{name,date,severity,tags} + campaigns[].campaign_threat_actor, repeat_offender, repeat_offender_reason, incident_count_365d, days_since_last_incident, decay_phase
malwareverdict + malware_signals[] (with malware_signal_id), mitre, iocs, rollup + chains, phx_neural_score
provenance.block_sources / block_freshness / block_errors / block_notesvisible
provenance.intel_license(hidden) {product, tier, source}

The provenance.block_* maps are keyed by token name, and those keys carry their own classifications — so a Free caller sees provenance for exactly the blocks it can see, and no more. That consistency is deliberate.

Present-but-empty vs. absent is not arbitrary. A block whose container key is itself classified (licensing REGISTERED, campaign PRO) is omitted entirely below that tier. A block whose container is PUBLIC (vulnerabilities, malware, exploitation) is present with only the fields the tier may see — so exploitation comes back as {} for Free and Registered rather than disappearing. This matches the surface's existing empty-container convention documented above; it is the deterministic-friendly shape and should not be "fixed" to key omission.

Several envelope field names are not the underlying source's names, and the renames are load-bearing rather than cosmetic. data_shield's classification map is flat and global, so a nested key here shares one classification with every dataset on the platform. Full table and rationale in PAI_INTERNAL_API.html; the short version is malware_signals / malware_signal_id / mitre_technique_ids (the source names are ENTERPRISE and would leave Pro with unactionable data) and licensing / license_policy / license_risk_score / block_counts / tte_hours / campaign_threat_actor / intel_license (the source names are unclassified, i.e. dropped platform-wide, so classifying them would newly expose another dataset's fields).

allow_clean is not widened by a Global Intel bundle. Composition reuses the malware resolver in-process with allow_clean at the same value the rest of this route uses — is_global_admin, per the section above. A non-admin caller at any tier, holding an Enterprise bundle, gets no expanded.malware block for a SUSPECT-band package: not a redacted one, not an empty one. A product entitlement is not the separately-decided authorization to see pre-confirmation triage state.

Status codes.

ConditionBehaviour
No includeByte-identical to the pre-Plan-D response.
Unknown token400, detail naming the unknown token(s) and listing every valid token.
enable_intel_query_surface off404 — checked before include parsing and before licence resolution, so neither a 400 nor a 403 leaks the feature's existence.
enable_global_intel_license off, or no bundle403 {"detail": "Global Intel license required for expanded intelligence"}and the base envelope is still in the response body. Losing the bundle narrows the answer; it does not delete it.
Daily intel_expanded quota exhausted429. Checked after the licence gate and before composition, so a rate-limited call costs a dict lookup rather than a full fan-out.
Anonymous caller, licence-gated token403 carrying the base envelope, from the route's own licence gate — an anonymous caller holds no bundle. The IP-keyed Free-tier quota deliberately does not intercept this: the middleware has no envelope or licence context and could only emit a bodiless {"detail": …}, which would contradict the row above. The call still consumes the anonymous search quota (429-able). ?include=advisory is unaffected.

Pagination. limit/offset on the search route are clamped to the caller's real tier cap, not to the 1-200 range in the signature.

MCP — intel_get with include

The intel_get MCP tool accepts the same include tokens, validated by the same registry helper. Two differences from REST, both deliberate:

The daily intel_expanded quota is enforced here, through the same shared helper, charged only when a licence was resolved and before composition runs — MCP requests are classified as mcp by the metering middleware and never reach the intel branch, so this is the only thing standing between a bundle holder on MCP and unmetered composition.

The bundle is read from the calling API key's own global_intel_tier stamp. MCP user tier confers nothing: enterprise MCP access is not a Global Intel licence. When enable_intel_query_surface is off the intel tools are absent from tools/list, not merely erroring on tools/call.

CLI — phoenix-cli intel get --include

phoenix-cli intel get library_version pkg:npm/left-pad@1.3.0 --include vulnerabilities,malware
phoenix-cli intel get library pkg:npm/left-pad --include all --deterministic-only

--include accepts CSV or repetition and is forwarded verbatim; the CLI has no gate of its own and inherits the flag, licence, quota and tier shaping of the /api/v1/intel/* endpoint it calls. It deliberately does not validate tokens locally — a CLI released before a token existed must not be able to refuse it.

--deterministic-only nulls advisory, expanded and provenance.block_freshness. expanded carries free text (CVE summaries, campaign names, block notes), and block_freshness is the artifact's generation timestamp, which moves whenever the enrichment lane reruns — either would break the byte-stability the flag exists to guarantee. (provenance.evaluated_at is stamped per response and was already non-reproducible before this flag existed; it is deliberately left alone.)

Design Deviations

Two deliberate departures from the original design doc — intentional, not oversights:

Known rollout gates

This surface is flagged off everywhere today. These are tracked, open items — not defects — that gate enabling enable_intel_query_surface:

  1. Usage metering doesn't recognize this route family (anonymous-by-IP leg only — see the final-review note below). /api/v1/intel/* isn't matched by usage_metering.classify_request, so a truly anonymous caller (no JWT, no API key) bypasses the middleware-level, IP-keyed daily quota enforcement (_enforce_anonymous_limits in main.py) that /api/v1/cves/* already gets. Must still be added before enabling with real anonymous traffic.
  2. Not yet in the public Swagger allow-list. /api/v1/intel is not yet in _PUBLIC_API_PREFIXES in backend/app/main.py, so this surface stays invisible in /api/docs even once the flag is on.
  3. A CPE search-result identity quirk. CpeResolver.search()'s envelope id (Public, as a universal wrapper field) incidentally discloses the same vendor:product pairing that identity.cpe_vendor/identity.cpe_product (Registered) gates for anonymous callers. Low impact (NVD-public data) but needs explicit sign-off.
  4. Empty containers are the expected shape for gated blocks (see the tier matrix above) — called out again because it is the item most likely to get "fixed" by mistake.
  5. Pro tier currently gets no malware signal-category insight at all. The design envisioned a coarse "categories only" view for Pro, distinct from Enterprise's full signal IDs; Pro gets neither today — a completeness gap (never a leak), tracked as Plan D territory.
  6. offset is inconsistently honored across entities. cve/cwe/cpe correctly page and return [] once exhausted; the other 5 entities (vendor, product, library, library_version, malware) are exact-lookup searches that ignore offset and return the single matching item regardless. Low impact today, but should be closed or documented per-entity before this surface is presented as uniformly paginable.
  7. allow_clean entity-name matching is normalized in registry.py but not yet in the REST router. intel_query.py's own decision of whether to compute allow_clean at all still compares the raw, case-sensitive entity path parameter. Fails closed today (a request to /api/v1/intel/Malware/... never gets allow_clean=True even for a global admin) — not a leak, flagged so a future registry.py-only fix isn't assumed to close this end-to-end.

Final whole-branch review fix-round (2026-07-25) closed the search route's performance/quota gap the whole-plan review surfaced (item 1 above only ever covered the separate, IP-keyed anonymous-metering leg — this is additional, not a duplicate):

Tenant-Scoped Research Triggers

/api/v1/research/*
Note: Added 2026-07-26 (Plan C). Gated by feature flag enable_intel_research_triggers (SITE_CONFIG, default false). When the flag is off, every route below returns 404 (not 403).

Lets a Pro or Enterprise customer trigger Phoenix research on a package or a CVE — and poll for the result — without a global-admin key. One job table (intel_research_jobs) and one dispatcher back this surface and its PAI (/internal/v1/research/*, see PAI_INTERNAL_API.html), MCP (intel_research_start/intel_research_status, see MCP_API_INTEGRATION_GUIDE.html), and phoenix-cli research mirrors, so a customer-triggered scan and an admin-triggered scan run identical worker logic and cannot diverge in what they write to mpi_scan_results.

MethodPathPurpose
POST/api/v1/researchTrigger research. Body: {"kind": "...", "target": "..."}. Returns 202.
GET/api/v1/research/{job_id}Get one job. Another org's job reads as 404, never 403.
GET/api/v1/researchList this organisation's jobs (state, limit 1–200 default 50, offset).

Requires an authenticated identity (Cognito JWT or API key) and a Pro or Enterprise subscription (403 otherwise). org_id resolves from the API key's own org_id, or from the caller's tenant membership for Cognito callers — not from the free-text, self-edit-able users.organization profile field.

kindTargetExample
PACKAGE_SCANVersioned purlpkg:npm/express@4.17.1
CVE_ANALYSISCVE idCVE-2024-27198
PACKAGE_HISTORY_REFRESHPurl base (no version)pkg:npm/express

An invalid kind or a malformed target for its kind returns 422.

States: QUEUED -> RUNNING -> {COMPLETED, FAILED, EXPIRED}; REJECTED is reachable only from QUEUED. The last four are terminal and never transition out (enforced by application logic and a DB constraint). The response field is job_state, not state — renamed at the response boundary to avoid colliding with an unrelated, already-classified "state" field on the CVE Core Fields dataset in data_shield.py's global classification map.

Quota: Pro 25/day, Enterprise 250/day, per org (429 when exceeded). A duplicate request (same org/kind/target) while an earlier one is still QUEUED/RUNNING returns the same job id and does not consume a second quota unit; a request identical to an already-terminal job creates a new job.

{
  "job_id": "5e6f...-uuid",
  "org_id": "org-a",
  "kind": "PACKAGE_SCAN",
  "target": "pkg:npm/express@4.17.1",
  "job_state": "QUEUED",
  "result": null,
  "error": null,
  "created_at": "2026-07-26T00:00:00+00:00",
  "started_at": null,
  "finished_at": null
}

result is null until COMPLETED, then holds the worker's JSONB output (shape depends on kind; PACKAGE_HISTORY_REFRESH returns Plan B's build_malware_history() output directly, the other two return their own worker's raw payload — wrapping them in Plan A's query-surface envelope is a tracked follow-up). error is populated (truncated to 2048 chars) when job_state is FAILED.

Unlike /api/v1/malware-intel/* (require_global_admin-gated, no per-org scoping by design), this surface has no cross-org read path at all, not even for a global admin.

User Profile — GET /api/v1/user/me/tier

Added 2026-08-17. This route is public/api/v1/user is in _PUBLIC_API_PREFIXES (main.py:632) and the path carries no /admin segment. Gated by feature flag enable_global_intel_license (existing, default false). When the flag is off, the response is byte-identical to before this change — the scope is simply never appended.

Returns the caller's tier, allowed API-key scopes, and usage data. allowed_scopes may now additionally include api_global_intel when the caller holds a live Global Intel bundle. The scope is what lights up the "Intel Bundle" category on the self-service API-key form (web/user-dashboard.html) and is what POST /api/v1/user/me/api-keys requires to mint a phx_gintel_ key — the two paths share one resolver, so a caller is never shown the card and then refused at mint.

This is additive only — the bundle scope is appended on top of whatever TIER_ALLOWED_SCOPES[account_tier] already grants; it never removes a scope the account tier already carries.

Resolution, combined with max_by_rank (the higher of the two wins):

Rollout prerequisite — this path requires BOTH enable_global_intel_license AND enable_mpi_tier_separation. The user-level path above needs only the first flag. With only enable_global_intel_license on, a user-level grant still works, but an org-level grant silently contributes nothing — no error anywhere, on this endpoint or on the admin org table. An operator who grants the bundle to an org without also having flipped enable_mpi_tier_separation will see no effect and no diagnostic. This is fail-closed by design (it narrows the reporting gate to match the one that actually enforces MPI/firewall tier lift elsewhere) and is pinned by test, not an oversight — see docs/Individual_Feature/2026-08-17-global-intel-org-column-and-key-category.md before granting an org-level bundle.

Known limitation — multi-org users. A user belonging to more than one org resolves the org-level grant against a single membership row (get_user_tenant_membership, one row, ORDER BY tenant_id). If the bundle lives in a different org than the one that resolves, the scope does not appear on this endpoint. This is a deliberate, existing limitation shared with the admin panel's own resolution, not new behavior introduced here.

Known limitation — org/team grants do not expire. Only the user-level product_entitlements row carries expires_at. tenant_product_entitlements and team_product_overrides have no expiry column, so an org- or team-level bundle grant does not lapse on its own — it is revoked by downgrading or deleting the row by hand.

User Profile — GET /api/v1/user/me/global-intel-tier

Documented 2026-08-18. Same public prefix and same enable_global_intel_license gate as /me/tier above; 404 when the flag is off, 403 when the caller holds no bundle.

Self-service “what am I entitled to” for the Global Intel bundle. Returns the resolved global_intel_tier, the scopes and key/expiry caps that tier allows, and an expiry block.

expiry.scope (added 2026-08-18) — read this before rendering a lapse banner. The tier is resolved as the higher of the caller's user-level entitlement and their org/team grant. Expiry, however, can only ever come from the user-level row: tenant_product_entitlements and team_product_overrides have no expires_at column. Before this field existed, a caller whose personal bundle had lapsed while their org held a live one was told global_intel_tier: "pro" alongside expiry.state: "expired" — a lapse warning for a licence that was not lapsing, and the same mismatch produced false expiring_soon warnings.

Clients should key any expiry UI off expiry.scope together with expiry.state, never off state alone.

Response schema

{
  "global_intel_tier": "pro",              // bundle tier: "pro" | "enterprise"
  "allowed_scopes": ["api_global_intel"],
  "max_keys": 5,
  "max_expiry_days": 365,
  "max_api_key_users": 10,
  "expiry": { "scope": "user", "state": "active", "expires_at": "2027-01-31" },
  "limits": {
    "max_page_size": 500,
    "daily_record_budget": 250000,
    "api_rate": { },
    "features": { }
  },
  "enforcement": {
    "limits_apply_to": "api_key",
    "api_key_tier": "pro",
    "session_tier": "registered",
    "session_lift_wired": false,
    "note": "..."
  }
}

Platform tier and bundle tier are different things. Platform tiers are free / registered / pro / enterprise; the Global Intel bundle has only pro and enterprise. A Free or Registered account can hold a bundle — it raises the tier on an API key without changing the account's platform tier, which is why api_key_tier and session_tier are reported separately.

limits describes a phx_gintel_ API key, not this request. api_rate is quoted from the enforcement path, so for such a key those numbers are the numbers enforced. max_page_size, daily_record_budget and features resolve at the caller's unlifted tier and remain a statement of entitlement, not a readout of enforcement.

session_lift_wired is false. The bundle lift applies to API-key callers only; a JWT/session caller resolves purely from account roles, and this endpoint is itself JWT-authenticated. Tracked as ASPMAIN-6581: a customer who buys Global Intel Pro and authenticates by session does not receive Pro limits.

Errors. 404 when enable_global_intel_license is off (the feature's existence is not disclosed). 403 when the caller holds no live bundle. There is no tier-shaped variant: every entitled caller receives the same fields.

Token Hub BYOK Registration

/api/token-hub-registration

Gated by feature flag enable_token_hub_byok_registration (default false). When the flag is off these endpoints return 404 (not 403).

Tenant identity is derived server-side from the authenticated user's database tenant membership. Callers must not send customerId, orgId, or userId.

BYOK onboarding, key registration, key revocation, and key listing require an authenticated user with Token Hub organization membership. Token Hub upstream availability/auth failures return 503; invalid upstream responses return 502; revoke for a missing key returns 404.

MethodPathPurpose
POST/api/token-hub-registration/byok/onboardingRegister tenant BYOK in one Token Hub onboarding call.
POST/api/token-hub-registration/byok/api-keyRegister or replace a BYOK API key.
POST/api/token-hub-registration/byok/keys/{platform}/{vendor}/revokeRevoke an active BYOK key.
GET/api/token-hub-registration/byok/keysList BYOK key summaries; secretRef is never returned.

Accepted platform values: blue, purple, green. Accepted vendor values: openai, anthropic, gemini.

BYOK key list response

{
  "orgId": "bb610723-161c-4e63-807a-0ca0d207606f",
  "keys": [
    {
      "orgId": "bb610723-161c-4e63-807a-0ca0d207606f",
      "platform": "blue",
      "vendor": "anthropic",
      "displayName": "Anthropic Production Key",
      "apiKey": "sk-ant-...here"
    }
  ]
}

apiKey is a masked hint from Token Hub, not the raw secret. secretRef remains internal to Token Hub client DTOs and is stripped before returning public API responses.

Frontier Model Attribution (FMA)

GET /api/v1/ai-attributed-model-cves

Returns CVEs discovered, reported, or co-reported with the assistance of frontier AI models (Claude, GPT, Gemini, Llama, Grok) and AI-native security firms (AISLE).

Gated by feature flag enable_frontier_model_cve_intel (default false). When the flag is off the endpoint returns 404 (not 403) so the feature's existence is not leaked.

Auth is optional: Free tier is rate-limited; Pro+ unlocks bulk access.

Query parameters

ParamTypeDescription
providercsv stringFilter by provider(s): anthropic,openai,google,meta,xai,aisle
programcsv stringFilter by program: glasswing,quiltworks,madbugs
min_confidence_tierint (1–3)Default 1
min_cvssfloatDefault none
sinceISO dateFilter discovered_date >= since
cursorstringPagination cursor
limitintDefault 50, max 200

Tier-restricted fields are filtered through ResponseTransformer: confidence_tier and credit_text/collaboration_chain require Pro+, the numeric confidence_score requires Enterprise. The CSV variant GET /api/v1/ai-attributed-model-cves.csv returns 501 Not Implemented in v1.0.

SCF Firewall Evaluate

The core Supply Chain Firewall rule-evaluation endpoint. Evaluates a batch of packages against the caller's enabled scf_firewall_rules and returns a per-package action plus MPI (Malware Package Intelligence) context. This is the endpoint the phoenix-firewall CLI/proxy and CI integrations call on every dependency resolution.

Flags: enable_supply_chain_firewall and enable_firewall_rules_engine (both true in the current rollout) gate the whole endpoint with 503 when off. enable_firewall_approval_workflow and enable_firewall_approval_raise_on_evaluate (both true in the current rollout as of 2026-07-19) together gate the approval_ids response field. With either flag off, approval_ids is always []. See below.

POST /api/v1/firewall/evaluate

Auth:

Request body:

{
  "packages": [
    { "ecosystem": "npm", "name": "left-pad", "version": "1.3.0" }
  ]
}

packages is required, 1–500 entries. Each entry requires ecosystem, name, version (strings).

Response 200:

{
  "results": [
    {
      "package": "left-pad",
      "version": "1.3.0",
      "ecosystem": "npm",
      "action": "require_approval",
      "matching_rules": [
        { "rule_id": "a1b2c3d4-...", "name": "Block new-maintainer packages", "priority": 100 }
      ],
      "mpi": {
        "signals": ["typosquat_suspected"],
        "confidence": 0.72,
        "threat_type": "supply_chain_compromise",
        "mitre_techniques": ["T1195.002"],
        "iocs": {}
      },
      "ps_oss_score": 42,
      "package_age_hours": 3.5,
      "trust_context": {},
      "notifications": ["slack:#scf-alerts"]
    }
  ],
  "approval_ids": ["7c2e1f9a-...uuid"],
  "evaluated_at": "2026-07-19T00:00:00+00:00",
  "cache_ttl_seconds": 300
}
FieldTypeNotes
results[]arrayOne entry per requested package, in request order
results[].actionstringallow | audit | warn | require_approval | block — the highest-precedence action across all matching rules (block > require_approval > warn > audit > allow); allow when no rule matches
results[].matching_rules[]array{rule_id, name, priority} for every enabled rule that matched
results[].mpiobject{signals[], confidence, threat_type, mitre_techniques[], iocs{}} — MPI context for the package
results[].ps_oss_scoreint | nullProprietary PS-OSS trust score, when available
results[].package_age_hoursfloat | nullPackage publish age, when available
results[].trust_contextobjectAdditional maintainer/publish trust signals
results[].notificationsarray[string]Deduplicated notification targets (e.g. slack:#channel) collected from matching rules
approval_idsarray[string]IDs of approval-queue entries raised for any require_approval results in this batch. Empty unless both enable_firewall_approval_workflow and enable_firewall_approval_raise_on_evaluate (both true in the current rollout) are on and the caller has Enterprise approval access (same Enterprise-tier-or-admin gate as the approvals queue). A require_approval result only raises an entry if at least one of its matching_rules[] carries a non-empty rule_id (the queue row's rule_id is cast ::uuid on insert); results without one are silently skipped. Repeated evaluations of the same pending package/version/ecosystem/user are deduped to the existing entry rather than creating a duplicate — including under concurrent requests, backed by a database-level unique constraint (migration 112), not just an in-memory check. Rows are visible via GET /api/v1/firewall/approvals only when show_firewall_approvals_page is also on (a separate flag) — see that endpoint's doc. Raising is additive and non-fatal: a failure to enqueue an approval never fails the evaluate call. The enqueue (DB write + optional Slack post) runs off the request's event loop, so a slow/unresponsive Slack webhook cannot stall other in-flight /evaluate calls.
evaluated_atstring (ISO 8601)Timestamp the evaluation completed
cache_ttl_secondsintAlways 300; advisory for client-side caching, not enforced server-side

When enable_firewall_webhook_notifications is on (true in the current rollout), the endpoint separately dispatches an out-of-band webhook/Slack alert for every block or warn result, and for require_approval results only if no approval was actually raised for that specific result (raising is skipped, e.g., when either approval flag is off, the caller lacks Enterprise approval access, or the enqueue failed non-fatally). When an approval is raised, its own interactive Approve/Deny Slack message is sent instead of the generic alert, so a require_approval result never produces two Slack messages for the same evaluation.

Downstream failures (rule loading, MPI lookup, etc.) return 500 with a generic "Evaluation failed" detail. Approval-enqueue and notification-dispatch failures are logged and swallowed rather than failing the request — approval_ids can legitimately come back [] even when a require_approval result is present, if enqueueing failed non-fatally (in which case the generic notification above still fires for that result, per the fallback described above).

Related: SCF Seat-Based Licensing (call-quota enforcement), docs/plans/2026-07-18-scf-approval-raise-request-wiring.md

SCF Seat-Based Licensing

Flag: enable_scf_seat_licensing (default true in the current rollout). These endpoints return 404 when the flag is off.

GET /api/v1/firewall/license

Returns the authenticated caller's organization SCF license, computed quota, and live usage.

Auth: Requires an authenticated user bound to an SCF organization.

{
  "license": { "org_key": "acme", "tier": "registered", "base_seats": 3, "purchased_seats": 0, "endpoints_per_seat": null, "calls_per_seat": null, "tenant_id": "..." },
  "quota":   { "tier": "registered", "total_seats": 3, "endpoints_per_seat": 3, "calls_per_seat": 1000, "endpoint_topup": 0, "pooled": false, "endpoint_quota": 0, "call_quota": 0, "per_seat_endpoint_quota": 3, "per_seat_call_quota": 1000 },
  "usage":   { "seats_used": 2, "endpoints_used": 7, "calls_used": 1234 }
}

POST /api/v1/firewall/license/seat-request

Submit a seat-purchase request. Global admins approve or reject the request; callers cannot allocate seats directly.

Request body: { "requested_seats": 10, "note": "optional" }. requested_seats must be greater than 0; note max 500 chars.

Response 200: Creates an scf_seat_request row with status: "pending".

Related: docs/Individual_Feature/2026-06-17-scf-seat-licensing.md

Note: Refer to Public Swagger for full endpoint definitions for the public surface only. Admin (/api/v1/admin/*) and PAI/internal (/internal/v1/*) endpoints are excluded from the public schema.

SCF Firewall Enrollment

Added 2026-07-14. Self-service alternative to distributing a reusable phx_fw_ key for device enrollment — a logged-in user mints a single-use, self-binding enrollment token instead.

POST /api/v1/firewall/enrollment-tokens

Issues a single-use enrollment token that a device redeems via phoenix-firewall enroll --bootstrap-token <token> to obtain its device-bound phx_fwagent_* agent key, without ever handling a reusable phx_fw_* key.

Auth: Required JWT/cookie session (logged-in user) — same as other /api/v1/firewall user endpoints.

Request body:

{
  "ttl_hours": 168,
  "name": "laptop-enrollment"
}
FieldTypeDefaultNotes
ttl_hoursint (1–720)168Token validity window before expiry
namestring, optionalMax 128 chars; operator-facing label

Response 200:

{
  "token": "phx_fwenroll_...",
  "token_id": "b3f1c2a4-...",
  "expires_at": "2026-07-21T00:00:00+00:00"
}

The token (phx_fwenroll_ prefix) is single-use and self-binding — the enrolling device binds itself to the token on first phoenix-firewall enroll --bootstrap-token <token> call; no pre-registration of the device is required. A second redemption attempt is rejected.

Related: Platform API Keys §9.4 Self-service enrollment token, docs/features/2026-07-14-scf-firewall-enrollment.md

SCF Exception Requests

Added 2026-07-19. Flag: enable_firewall_exception_requests (SITE_CONFIG, default off). All endpoints below return 404 (not 403) when the flag is off, so the feature's existence is not leaked. Auth: JWT/cookie session, same as other /api/v1/firewall user endpoints.

A registered user whose package install was blocked (or held at require_approval) can raise a self-service exception request instead of contacting an admin out-of-band. scope/scope_ref and tenant_id are always server-derived — any client-supplied value in the request body is silently discarded, never honored, so a probing client learns nothing from the response.

MethodPathWho
POST/api/v1/firewall/exceptionsany registered user
GET/api/v1/firewall/exceptionsown (member) / team (team admin) / tenant-wide (tenant admin)
GET/api/v1/firewall/exceptions/{id}owner or a decider whose scope covers the requester
POST/api/v1/firewall/exceptions/{id}/approvedecider only (see Decision authority)
POST/api/v1/firewall/exceptions/{id}/rejectdecider only
POST/api/v1/firewall/exceptions/bulk-previewdecider (read-only, server-computed)
POST/api/v1/firewall/exceptions/bulk-approvedecider (team or org)
POST/api/v1/firewall/exceptions/bulk-rejectdecider (team or org)

POST /api/v1/firewall/exceptions

Request body:

FieldTypeNotes
package_namestringrequired
package_version, ecosystemstring, optional
target_kindstringpackage | agent_skill | openvsx, default package
scopestringuser | team | org (endpoint is rejected — admin-authored only, not requestable here)
scope_refstring, optionalignored — server-derived; see below
tenant_idstring, optionalignored — always the caller's resolved SCF tenant
reasonstringrequired, non-blank, max 2000 chars
audit_log_idstring, optionallinks the request back to the blocking /audit row
requested_expiry_daysint, optionaldefault 90; 0 = permanent

scope_ref resolution (client input discarded in every case):

Requested scopeStored scope_ref
userthe resolved requester's own id, always
orgnull
teamthe requester's own team id — 403 if the client-supplied team is not one they belong to
endpoint403 — not requestable through this endpoint

Response 200: the created request row (status: "pending"). 400: blank/whitespace reason. 409: a pending request already exists for this exact (package, version, ecosystem, scope, scope_ref).

GET /api/v1/firewall/exceptions

Query filters: status, scope, package_name, user_id (admin-only, ignored for non-privileged callers). Row visibility: a plain member sees only their own requests; a team admin sees their teams' members' requests; a tenant admin / platform admin sees the whole tenant.

POST /api/v1/firewall/exceptions/{id}/approve / .../reject

Approve body: { "decision_reason"?: string, "expires_days"?: int, "malicious_override_ack"?: bool }. Reject body: { "decision_reason": string }required; blank/whitespace-only → 400.

Approving writes a scf_allowlist entry at the request's granted scope. If the target has a confirmed (status: "exact") malicious linkage and malicious_override_ack was not set, the response is 409 with {"message": ..., "linkage": {"status", "signals", "sources"}} — retry with the ack flag once reviewed.

400 — wildcard version over a package with confirmed-malicious versions. If the request carries no package_version (or ""/"*") and the package has versions confirmed malicious, the linkage is status: "wildcard_covers_compromised" and approval is refused outright — this is a 400, not a 409, and malicious_override_ack does not unblock it. The requester must re-raise the request naming a concrete non-malicious version; the error message lists the confirmed-malicious versions. See the allowlist section below for the rationale.

Decision authority (never a bare role check) is computed per-request from the request's own scope/scope_ref against the decider's tenancy context:

Decideruser-scopeteam-scopeorg-scope
Tenant admin / platform adminyesyesyes
Team adminyes, if the requester is on their teamyes, for their own team onlyno — 403, escalate to an org admin
Plain memberno — 403 (no self-approval)nono

A request outside the decider's visibility (belongs to a requester they cannot see at all) returns a plain 404 — identical to "doesn't exist," so a caller cannot use response codes to probe what exists elsewhere. A request that IS visible but not decidable (e.g. a team admin looking at their own team member's org-scope request) returns 403 with an explicit reason string so a review console can render "needs an org admin" as disabled-with-reason rather than a bare failure.

Bulk endpoints

POST .../bulk-preview { "exception_ids": [...], "action": "approve"|"reject" } is read-only and returns server-computed counts — total, by_scope, org_scope_count, malicious_flagged, malicious_refused, unauthorized, decidable — for the client's confirmation dialog. The client never computes these itself. malicious_flagged items need an acknowledgement; malicious_refused items (wildcard version over confirmed-malicious versions) can never be approved and are excluded from decidable, so the dialog does not promise an approval the write path would refuse.

POST .../bulk-approve { "exception_ids": [...], "decision_reason"?, "expires_days"?, "malicious_override_ack"? } and POST .../bulk-reject { "exception_ids": [...], "decision_reason" } (reason required, same as single reject) gate every item individually — authority, tier, and malicious linkage — so a batch never approves/rejects an item it would refuse individually. Responses partition: {"approved"|"rejected": [...], "flagged": [...], "refused": [...], "failed": [...]}. Malicious-flagged items are neither approved nor dropped — they come back in flagged for individual handling. refused is a separate bucket for items permanently refused (wildcard version over confirmed-malicious versions): unlike flagged, retrying with malicious_override_ack will not approve them, so a console must not offer an ack action for a refused item. exception_ids is capped at 200; over-length or empty → 400.

GET /api/v1/firewall/audit — relaxed scoping (Task 7)

Historically Enterprise-tier-or-admin only. With enable_firewall_exception_requests on, this gate relaxes: any registered user may call it, scoped to their own rows only unless they are a tenant admin (tenant-wide, with an admin-only user_id filter) or platform admin. With the flag off, behavior is unchanged — non-Enterprise, non-admin callers still get 403.

Field redaction is applied unconditionally, independent of the flag — every row is passed through an explicit allowlist projection, never a denylist:

FieldNon-privileged (own rows)Tenant admin / platform admin
log_id, package_name, package_version, ecosystem, action_taken, target_kind, created_atshownshown
reason_category (derived: Malware indicators | Vulnerability | Package reputation | Policy rule)shownshown
mpi_signals, matching_rules, mpi_confidence, ps_oss_score, notifications_sent, build_context, rule_id, user_idomittedshown

Related: docs/plans/2026-07-18-firewall-package-exception-requests-design.md

SCF Tiered Allowlist

Added 2026-07-19. Flag: enable_firewall_tiered_allowlist (SITE_CONFIG, default off). All endpoints below return 404 (not 403) when the flag is off. Auth: JWT/cookie session. Admin-only (tenancy is_tenant_admin or platform _is_admin_user, never _is_admin_user alone) — every route below, including reads. This is the direct-authoring counterpart to exception approval (POST /exceptions/{id}/approve); both write the same scf_allowlist table through the same shared gates (scope_permitted_for_tier, malicious_linkage) so the two paths cannot drift.

MethodPathWho
GET/api/v1/firewall/allowlisttenant admin / platform admin
POST/api/v1/firewall/allowlisttenant admin / platform admin, tier-gated per scope
DELETE/api/v1/firewall/allowlist/{entry_id}tenant admin / platform admin, tier-gated per the entry's own scope
GET/api/v1/firewall/allowlist/exporttenant admin / platform admin
POST/api/v1/firewall/allowlist/importtenant admin / platform admin, tier-gated per scope, per-row
GET/api/v1/firewall/allowlist/versionagent (device_id) or interactive caller (as_user=true)
GET/api/v1/firewall/allowlist/effectiveagent (device_id) or interactive caller (as_user=true)

Tier + scope gating

ScopeTier requiredCap
org, user, endpointEnterprise only
teamPro or Enterprise200 libraries per team

GET /api/v1/firewall/allowlist

Query filters: scope, scope_ref, limit (default 50, max 200), offset. Returns {"items": [...], "total": N}, tenant-scoped.

POST /api/v1/firewall/allowlist

Request body:

FieldTypeNotes
package_namestringrequired
versionstring, optionalnull/* = all versions
ecosystemstringrequired when verdict is allow (400 if null/blank/whitespace); optional for verdict: "block"
target_kindstringpackage | agent_skill | openvsx, default package
scopestringuser | team | org | endpoint
scope_refstring, optionalrequired for every scope except org — unlike POST /exceptions, this is admin-authored and targets any identity in the tenant, not the caller's own; ignored (forced null) for org
verdictstringallow | block, default allow
overridablebooldefault true; false marks a non-bypassable security floor
expires_daysint, optionaldefault permanent; 0 = permanent
malicious_override_ackbooldefault false — see malicious linkage below

Guard order: flag → is_tenant_admin or platform _is_admin_userscope_permitted_for_tier(scope, actor_tier) (403 if not permitted) → ecosystem required (only when verdict == "allow", 400) → malicious linkage (only when verdict == "allow") → wildcard-version refusal (400) → ack gate (409) → 200-cap (only when scope == "team", 400 at 200 existing entries) → write.

400 — ecosystem is required for an allow. An allowlist row with no ecosystem matches every ecosystem at evaluation time, while the malicious-linkage gate that vets it can only look up one concrete ecosystem — so a null-ecosystem allow would be simultaneously the broadest possible grant and the least vetted one. block writes are deliberately unaffected: a null-ecosystem block is a legitimately broad denial, and breadth without vetting is safe in the restrictive direction. The same rule applies on POST /allowlist/import (the row is reported under skipped) and on exception approval (400; the requester must re-raise the request with an ecosystem).

400 — a wildcard version may not allow a package with confirmed-malicious versions. When verdict == "allow" and version is omitted, "", or "*", the entry grants every version of the package. If malicious-package intelligence has confirmed specific versions of that package as malicious, the linkage returns status: "wildcard_covers_compromised" and the write is refused — the error message names the confirmed-malicious versions, and the admin must specify a concrete non-malicious version instead.

This is a hard refusal, not an acknowledgement gate: unlike the "exact" case below, setting malicious_override_ack does not permit it. An ack on an exact request consents to one named, already-published version the operator can evaluate; a wildcard ack would consent to every version of the package for everyone in scope — including the known-malicious ones and every version published in future — which authoring-time consent cannot meaningfully cover. Note the status is deliberately distinct from "exact" in the audit trail: no version was named, so reporting an exact version match would misdescribe the evidence.

The guard fires only where confirmed-malicious evidence exists, so broadly allowlisting a package with no such evidence is unaffected. A name-level-only match ("indeterminate") is not a refusal — there is no confirmed version for a wildcard to cover. block writes are unaffected, on the same asymmetry as the ecosystem guard: a wildcard block is a legitimately broad denial. The same rule applies on POST /allowlist/import (row reported under errors, counted in failed), on exception approval (400), and on bulk-approve (item lands in refused, not flagged).

409 conditions:

An "indeterminate" linkage (name-level match, no version detail) proceeds (fail-open, design spec §11.5.3) and is recorded as a distinct allowlist.malicious_indeterminate_pass audit entry — visible but non-blocking. This is emitted per item on both the single-approve and bulk-approve paths, so the two are directly comparable.

Gate posture (allowlist_malicious_gate_posture). A server-side configuration value, not a feature flag and not client-controllable: fail_open (default) or fail_closed, resolved from the ALLOWLIST_MALICIOUS_GATE_POSTURE environment variable, then site_config.json. Under fail_closed, an "indeterminate" linkage also requires malicious_override_ack and returns the same 409 shape an "exact" linkage does (with a message naming the posture). An unrecognised value falls back to fail_open. This is the documented operational lever for tightening the posture if indeterminate-pass volume proves high in production, without a code change.

Every successful write bumps the (tenant, scope, scope_ref) version counter consumed by the agent/user frequent-fetch endpoints (Plan B Task 6).

DELETE /api/v1/firewall/allowlist/{entry_id}

Gated identically to POST (tenant admin or platform admin) — not a bare scope-blind check: the entry is fetched first and scope_permitted_for_tier runs against the entry's own scope (a Pro admin cannot delete an Enterprise-only-scoped entry, even one they could never have authored). 404 if the entry does not exist in the caller's tenant.

Deleting a non-overridable entry (a confirmed-malicious hard block) additionally requires ?force=true and real global-admin privilege (the global-admin Cognito group — a stricter bar than tenant-admin). A denied attempt against a hard block is still recorded, as allowlist.delete_denied_hard_block — an audit trail of who tried to remove a confirmed-malicious floor is a signal worth keeping even when the deletion is refused.

GET /api/v1/firewall/allowlist/export / POST /api/v1/firewall/allowlist/import

Added 2026-07-19 (Plan B Task 5). Mirrors /rules/export + /rules/import in envelope shape (JSON body, not multipart — the frontend reads a file locally and POSTs it) but with three deliberate deviations, since that pair carries two known defects:

  1. No mode/replace option at all — import is append-only. /rules/import's replace mode deletes using only the caller's own user_id, while /rules/export reads under the full admin user_ids scope — an admin's "replace" there only clears their own rules, not the tenant's. Rather than reconcile that asymmetry, this endpoint never deletes; use DELETE /allowlist/{entry_id} for explicit removal.
  2. Import rows are capped at 1000 and an over-cap payload is rejected with 400 before any write. /rules/import has no such cap.
  3. Consistent scoping. Both routes read/write under access_context.tenant_id — the same tenant-wide admin scope GET /allowlist already uses — so there is no read/write scoping mismatch to reintroduce.

GET /api/v1/firewall/allowlist/export?format=json|csv (default json)

Same admin gate as GET /allowlist. Returns every allowlist entry in the caller's tenant.

POST /api/v1/firewall/allowlist/import

Request body:

FieldTypeNotes
formatstringjson | csv, default json
entriesarray of objectsrequired when format=json — same row shape as export's entries
contentstringrequired when format=csv — raw CSV text, same columns as export

Each row accepts the same fields as POST /allowlist's body (package_name, version, ecosystem, target_kind, scope, scope_ref, verdict, overridable, malicious_override_ack), plus expires_at (absolute ISO-8601 timestamp, for round-tripping an export) as an alternative to expires_days (relative). CSV blanks are treated as "field absent" so defaults apply.

Row count cap: payloads over 1000 rows are rejected with 400 before any row is processed.

Per-row pipeline (identical to POST /allowlist, run independently for every row so one bad row never aborts the batch): scope_permitted_for_tier (real, shared gate) → scope_ref resolution → malicious linkage (verdict == "allow" only) → 200-per-team cap (tracked with an in-batch running counter, since the DB count alone would not see rows admitted earlier in the same not-yet-written batch) → write.

Admitted rows are written with one bulk storage call wrapping the whole batch in a single connection/transaction with a per-row SAVEPOINT — a genuine DB-level failure on one row rolls back only that row (ROLLBACK TO SAVEPOINT), not rows already processed earlier in the same request.

Response: {"status": "ok", "imported": N, "failed": N, "errors": [{"index", "package_name", "reason"}, ...], "flagged": [{"index", "package_name", "version", "linkage"}, ...]}

Matches the house /rules/import envelope (status, imported, failed, errors), plus flagged as this route's one addition.

Every successful row bumps the (tenant, scope, scope_ref) version counter (deduplicated across the batch, one bump per distinct scope/scope_ref pair, not one per row).

GET /api/v1/firewall/allowlist/version / GET /api/v1/firewall/allowlist/effective

Added 2026-07-19 (Plan B Task 6). Unlike every other route in this section, these two are NOT admin-only — they are the versioned frequent-fetch API design spec §6/§11.7 describe as serving "agent/user": an agent-bridge process (or dashboard user) polling for its own effective policy, not a tenant admin browsing the tenant-wide library. Auth: phx_fw_* self-service key or JWT — the same _resolve_firewall_caller dependency POST /evaluate uses. Never a FIREWALL_AGENT (phx_fwagent_) device-bound key; that key's own endpoints live under /api/v1/firewall/agent (firewall_agent.py) and are unrelated to this pair.

Query parameters (exactly one required — 400 if both or neither are supplied):

ParamTypeMeaning
device_idstring (UUID)Resolve the cascade for this enrolled device: endpoint ⊕ its team (if assigned) ⊕ org. 404 if the device is not enrolled in the caller's own tenant (or the id is malformed → 422). Tenant is always resolved from the authenticated caller, never from device_id itself — device_id only narrows within that tenant.
as_userboolResolve the cascade for the calling user's own identity: user ⊕ their team(s) ⊕ org — via the same resolve_allowlist_scopes_for_user POST /evaluate already uses.

A caller with no resolvable SCF tenancy at all (e.g. a bare API-key caller never provisioned into scf_tenant_members) → 404 for either path, never a 500.

GET /api/v1/firewall/allowlist/version

Response: exactly {"version": "<sha256 hex digest>"}, plus response header ETag: <same hash>. Send If-None-Match: <last-seen hash> on a repeat poll — 304 (empty body, ETag echoed) when nothing in the cascade changed since.

The hash is sha256 over the sorted (scope, scope_ref, version) tuples for every scope in the resolved cascade, reading counters from scf_allowlist_versions (bumped on every scf_allowlist write — Plan B Tasks 1/4/5). A scope that has never been written contributes version 0 rather than being omitted, so the hash is deterministic and stable across calls with no writes, and changes on a write at any scope in the cascade — one check covers the whole org⊕team⊕user/endpoint chain.

GET /api/v1/firewall/allowlist/effective

Response: {"items": [...], "total": N, "floor_blocks": [...]}. Excludes expired rows (expires_at), matching list_allowlist_candidates's own filter.

Field projection (both arrays). Every object in items and floor_blocks carries exactly these fields and no others: package_name, version, ecosystem, target_kind, scope, scope_ref, verdict, overridable, expires_at.

This endpoint is intentionally not admin-gated — agents and ordinary tenant members must be able to fetch their own effective policy — so it reaches a wider audience than GET /allowlist, which is admin-gated. The underlying scf_allowlist rows also carry entry_id, created_by, malicious_ack_by, and malicious_ack_at (which admin overrode a confirmed-malicious block, and when). Those are never returned here. The projection is an explicit allowlist, not a denylist, so a column added to scf_allowlist by a future migration does not automatically become public.

Related: docs/plans/2026-07-18-firewall-tiered-allowlist-plan-B.md

Malware Public Intelligence — Package Version Timeline

The public malware intelligence API surfaces package-version vulnerability timelines, including malware verdict history.

GET /api/v1/malware/list — public malware package list (sources[] vocabulary)

Gated by show_malware_list_page (default false, 404 when off). Supports ?group=package (grouped by purl_base, built by fetch_malware_grouped()) and ?group=campaign (clustered; degrades to package mode when show_malware_campaign_clusters is off); default group=none is per-purl-version. Grouped/clustered rows include a sources[] array — a whitelisted, deduplicated list of the intel feeds that flagged the package:

ValueMeaning
PHXMINTMPI pipeline (LLM+judge) assessment
OSSFOpenSSF Malicious Packages feed
OSMOpen Source Malware feed
VULNDBVulnDB-sourced compromise intel
PHOENIX_RESEARCHPhoenix-authored research entry
MANUAL_VERIFIEDanalyst-curated campaign entry; a human asserted the compromise

SAFECHAIN/COMPROMISE-internal labels are stripped before reaching this list; only the whitelisted values above are ever returned.

Tier behavior: sources[] (and, on the detail route below, d14_sources) are not tier-redacted — _build_grouped_row() in backend/app/routers/malware_public.py copies sources through unconditionally at every tier, unlike latest_phx_neural_score (Enterprise/admin-only) and phoenix_risk_factors (Pro+/Enterprise/admin-only). This is pre-existing behavior for every value in the table above, not something introduced by adding MANUAL_VERIFIED. One consequence worth knowing: a MANUAL_VERIFIED entry only ever comes from a compromised_package_intel.source = 'manual_campaign' row, so a Free/Registered caller can infer manual-campaign curation from sources[] even when that same row’s campaign_id/campaign_name/campaign_description are withheld (campaign_locked: true) by the Pro+ campaign-intel gate — the source label and the campaign identity are gated independently, and only the latter is tier-restricted today.

Response caching — GET /api/v1/malware/list and GET /api/v1/malware/stats/*

Note: Gated by enable_mpi_response_cache (default false). When off, behavior is unchanged (every request hits the database).

When the flag is on, the first page of GET /api/v1/malware/list (no cursor, no free-text q), all GET /api/v1/malware/stats/* responses (overview, timeline, auto-blocks, repeat-offenders, news), and GET /api/v1/malware/campaigns are served from a short-TTL server-side cache (600 seconds by default; tunable via the PHX_MPI_RESPONSE_CACHE_TTL_SECONDS env var, clamped to 30–3600) and may therefore be up to ~10 minutes stale. The underlying data-updater cycle is 1 hour, so this staleness window is not observable in practice.

GET /api/v1/malware/{purl} and GET /api/v1/malware/package?purl= — public package detail (Purple contract B5)

Note: This route already existed and already returned versions[]; as of 2026-07-25 it is documented here for the first time and gains a query-parameter alias plus the history rollup fields below. Gated by show_malware_detail_page (default false, 404 when off) — a pre-existing, unrelated gate.

Because a path-embedded purl requires double-encoding / and @, and some HTTP clients silently normalize that away, GET /api/v1/malware/package?purl=<purl> is a query-parameter alias — it forwards to the exact same handler function, so the two forms are provably identical.

versions[].phx_neural_score is Enterprise-only (redacted to null for lower tiers); versions[].signal_diff_from_previous is redacted to null for Free.

Package malware history rollup (Purple contract B4) — same fields, same builder as /packages/intel

ever_malicious, first_flagged_at, version_verdicts, and detail_url are added the same way as on /packages/intel above, built from the already tier-redacted versions[].

Field-collision guard: this route's response already has a campaigns key (the legacy compromised_package_intel-derived list, a different shape than the rollup's own campaigns). The merge only adds keys not already present — never a blind overwrite — specifically so the rollup's own campaigns value can never clobber the pre-existing field with []. On this route, the rollup contributes ever_malicious, first_flagged_at, version_verdicts, and detail_url — but not a campaigns value.

d14_sources — INTEL SOURCES breakdown (attribution only)

GET /api/v1/malware/{purl} also returns a d14_sources object built by check_d14_union() in backend/app/services/malware_public_query.py, breaking down why a package is D-14-eligible:

KeyMeaning
mpi_verdict_maliciousProduction MPI consensus dossier verdict is malicious
mpi_pipeline_confirmedMPI pipeline (LLM+judge) verdict is CONFIRMED_MALICIOUS
compromised_intel_rowA compromised_package_intel row exists for this purl (any source)
feed_ossfcompromised_package_intel.source == 'ossf' for a matched row (or the legacy mpi_scan_results.feed_ossf column)
feed_osmcompromised_package_intel.source == 'osm' for a matched row (or the legacy feed_osm column)
feed_safechaincompromised_package_intel.source == 'safechain' for a matched row (or the legacy feed_safechain column)
manual_verified (boolean)a compromised_package_intel row for this purl has source='manual_campaign'. Attribution only; does not affect D-14 confirmation.
is_verified_maliciousTrue if any of mpi_verdict_malicious, mpi_pipeline_confirmed, compromised_intel_row, feed_ossf, feed_osm, or feed_safechain is True. manual_verified is deliberately not part of this ORcompromised_intel_row already covers every CPI row regardless of source, so folding manual_verified in would be redundant, and D-14 confirmation semantics are a tier/verdict-bearing contract this cosmetic attribution field has no business changing.

Verdict band vocabulary

The versions[] timeline array in malware API responses uses a closed, 3-value verdict vocabulary:

VerdictMeaning
CLEANThe package version is clean: no malware signals detected.
MALICIOUSThe package version is confirmed malicious.
SUSPECTThe package version is under review or exhibits suspicious indicators; confidence is uncertain.

Important: This is a closed set. The backend collapses an internal 8-value pipeline-lifecycle state space (CONFIRMED_MALICIOUS, MALICIOUS, SUSPECT, AWAITING_REVIEW, INCONCLUSIVE, LLM_ANALYSIS, CLEAN, DISPUTED_FP, and others) into these three public values. Consumers must not expect additional values to appear, and an unrecognized future internal state defaults to SUSPECT rather than being dropped, to ensure conservative handling of unknown cases.

GET /api/v1/malware/{purl:path}/reasoning — Enterprise MPI reasoning (Plan D)

Gated by enable_mpi_reasoning_enterprise (default false, 404 when off). Once on, this route is Enterprise or global-admin only — unlike the tier-shaped endpoints above, a Pro (or lower) caller gets 403, not a shaped 200, because this route's entire payload is the Enterprise-only content and a 200 with every field nulled would be a worse contract than an honest refusal.

Non-admin Enterprise callers additionally only get 200 for a package in a terminal, no-pending-review verdict state (CONFIRMED_MALICIOUS, CLEAN, DISPUTED_FP, PUBLISHED, or a compromise-intel-only confirmed-malicious record). A package still in an active-review state (SUSPECT, AWAITING_REVIEW, LLM_ANALYSIS, INCONCLUSIVE) returns the same 404 a non-existent scan would — indistinguishable from "never scanned" — so this route cannot be used to learn that a package is under review before a human has confirmed it. Global admins are exempt from this narrowing and see reasoning for every verdict state, matching the existing public dossier route's allow_clean=is_global_admin behavior.

Registered before the GET /{purl:path} catch-all above, for the same reason the /package alias is: an unordered catch-all would swallow pkg:npm/x@1.0.0/reasoning as a literal purl.

Returns the signals, MITRE techniques, chain matches, and LLM analyst/judge verdicts behind one MPI scan's decision, built by build_reasoning_view() (app/services/mpi_reasoning.py), tier-shaped and class-only (never a raw percentage).

{
  "purl": "pkg:npm/evil@1.0.0",
  "verdict": "MALICIOUS",
  "phx_neural": { "band": "H", "score": 91 },
  "signals": { "items": [ { "signal_id": "CS-001", "category": "...", "severity": "CRITICAL", "mitre_primary": "..." } ], "total": 1 },
  "mitre": { "tactics": ["..."], "technique_ids": ["..."] },
  "chains": [ { "pair": ["CS-001", "..."], "multiplier": 1.2 } ],
  "iocs": { "types": ["urls", "hashes"] },
  "llm": {
    "analyst": { "verdict": "MALICIOUS", "confidence_class": "VERY_HIGH", "reasoning": "..." },
    "judge": { "verdict": "MALICIOUS", "reasoning": "..." }
  }
}

Correction (2026-07-26): two independent builders, not one shared function feeding three planes

An earlier version of this section claimed a single build_reasoning_view() fed this route, the public dossier's LLM block, and the intel-query advisory block alike, "so the three planes cannot disagree." That claim is false for one of the three planes and has been corrected:

SurfaceBuilderConfidence field(s) present
Dossier (llm_analysis.analyst)_build_llm_analysis_block() legacy confidence (rounded 0-1 float, always present) + confidence_class (flag-gated)
/reasoning route (llm.analyst)build_reasoning_view() confidence_class only
Intel-query advisory (advisory.analyst)build_reasoning_view() confidence_class only

The "never emitted... any raw confidence float" note below describes only the two build_reasoning_view()-backed planes (this route and the advisory block). It does not describe the dossier, which deliberately keeps its legacy confidence percentage for one release.

Per-tier reasoning matrix — build_reasoning_view() internals; only the Enterprise row is reachable over HTTP

Correction (2026-07-26): an earlier version of this section implied Free/Registered/Pro/Enterprise were all reachable responses from this route or from the advisory plane. They are not. The table below documents build_reasoning_view()'s internal tier-branching logic (unit-tested at that granularity), but neither real consumer of this function ever calls it with anything other than tier="enterprise":

So in practice: only the Enterprise row below is ever visible to any HTTP caller, on either plane, and even then the advisory plane only ever surfaces its llm cell (see the shape note immediately below).

Tierverdict/phx_neuralsignalsmitreiocschainsllm
Free / Registeredband onlynullnullnullnullnull
Proband onlycategory counts only (no signal IDs)tactics onlytypes onlynullnull
Enterprise / PAI — the only reachable rowfull, + numeric scoreper-signal items + totaltactics + technique_idstypespair matches + multiplieranalyst + judge (no confidence_class on judge)

What the advisory plane actually returns. Per the "Advisory block opt-in" section above, ?include=advisory never carries verdict/phx_neural/signals/ mitre/iocs/chains at any tier — its populated shape is always exactly {"analyst": {...}, "judge": {...}, "reproducible": false} (the llm sub-block only, at hard-coded Enterprise fidelity). This section's table is build_reasoning_view()'s internal contract, not a description of that plane's actual response.

Not the same gap as rollout gate item 5. The "Pro tier currently receives no malware signal-category insight at all" item under "Known rollout gates" above describes a different surface — the base GET /api/v1/intel/malware/{id} envelope's deterministic.signals field, shaped by data_shield's field-level classification, not by this Enterprise-only /reasoning route or its advisory companion.

Never emitted by build_reasoning_view(), on either plane it backs, at any tier including Enterprise/PAI: llm_analyst_model, judge_model, llm_analyst_cost, judge_cost, raw IOC values (only IOC types are published), raw per-signal severity weights, and any raw confidence float — llm.analyst.confidence_class is always the published CLASS from the "Confidence class (published cutpoints)" table above, never a number. The judge stage stores no confidence value upstream, so llm.judge never carries a confidence_class key — an absent key is honest; a fabricated UNKNOWN is not.

confidenceconfidence_class migration (dossier only)

The public dossier ships both fields for one release: the legacy rounded confidence alongside the new confidence_class. confidence is deprecated and will be removed in a following release once consumers have migrated; no removal date is set yet. The /reasoning route and the intel-query advisory block never had a numeric field to deprecate.

Caution — the two fields can disagree by one bucket at a cutpoint boundary. confidence is bucket-rounded to the nearest 5 percentage points before publication, while confidence_class is derived from the raw, unrounded value. At a boundary (e.g. a raw 89% confidence), confidence rounds up to 0.90 while confidence_class classifies the unrounded 89% as HIGH (since 0.89 is below the VERY_HIGH cutpoint of 0.90) — so a reader can see confidence: 0.90 alongside confidence_class: "HIGH". Expected during the migration window, not a bug.

Design deviation: the dossier's confidence_class is gated by the flag alone, not by tier

confidence_class on the dossier is gated by enable_mpi_reasoning_enterprise alone — the dossier route runs no data_shield tier pass at all. Once the flag is on, every caller — including anonymous/Free — sees confidence_class on the dossier. Accepted as non-sensitive: confidence_class is strictly coarser than the already-fully-public rounded confidence percentage the same response already shows at every tier.

MPI Bulk-Process Queue

Server-side sequential batch worker for draining Kanban columns. Admin-only (requires MPI pipeline enabled).

POST /api/v1/malware-intel/queue/bulk-process

Schedule a bulk job; returns immediately with a job_id.

FieldTypeDefaultDescription
kanban_statusstring"awaiting_review"Source column
max_packagesint50Pipeline cap 1 000; approve_malicious cap 100 000
modestring"pipeline"pipeline = analyst+judge; approve_malicious = confirm by score
min_scoreint|nullnullOnly include packages with heuristic_score ≥ min_score
batch_sizeint50Items per batch (approve_malicious mode)
pause_secondsfloat0.0Pause between batches
pipeline_stepsint21=analyst, 2=analyst+judge, 3=+verifier
retry_inconsistentbooltrueRe-run pair on analyst/judge disagreement
reasonstring""Operator note for audit log

400: invalid mode, max_packages exceeds cap, pipeline_steps not in {1,2,3}. 429: job already running.

GET /api/v1/malware-intel/queue/bulk-process/{job_id}/status

Poll job progress. Returns { job_id, status, processed, total, batch_index, batch_total, started_at, finished_at, errors, mode }. status: queued → running → complete | error.

Related: docs/Individual_Feature/2026-06-26-mpi-score-batch-confirm.md

MPI Campaign Proposals

/api/v1/compromise-intel/campaigns — admin-only propose → approve/reject review workflow layered on top of the existing campaign CRUD endpoints on this router. Gated by feature flag CAMPAIGN_PROPOSALS_ENABLED (default false); every endpoint below returns 404 (not 403) when the flag is off. All endpoints require global-admin auth (require_global_admin), except where noted for the list endpoint.

GET /api/v1/compromise-intel/campaigns?view=existing|proposed

view defaults to existing (today's derived, always-current list; unaffected by this flag). view=proposed requires both the flag and the caller to be an authenticated global admin (checked via get_current_user_optional + is_global_admin inside the handler, since this endpoint has no blanket require_global_admin dependency at view=existing). A caller that fails either condition gets a plain 404 — an unauthenticated or non-admin caller cannot distinguish "flag off" from "not admin." Returns { total, limit, offset, results: [...] } where each result is a compromise_campaigns row with status='proposed', including campaign_metadata (packages/IOCs/hashes) and a derived package_count.

POST /api/v1/compromise-intel/campaigns (propose: bool)

Extends the existing create/update request body with propose: bool = false. propose=true writes a status='proposed' row — packages/IOCs/hashes are stored in the row's metadata jsonb, not linked to any package-intel table — and returns { campaign_id, campaign_name, status: "proposed", package_count }. propose=false (default) is the pre-existing, flag-independent direct-publish/update behavior.

400: re-proposing a campaign name whose slug already belongs to an active/historical campaign (the ON CONFLICT guard refuses to reset a live campaign back to proposed).

POST /api/v1/compromise-intel/campaigns/{campaign_id}/approve

Materializes a proposal's packages/IOCs/hashes onto the live surface via the same path used by direct-publish, then sets status='active'. 404 if campaign_id is not currently proposed (already approved/rejected, or never existed).

POST /api/v1/compromise-intel/campaigns/{campaign_id}/reject

Sets status='rejected' (soft, kept for audit; hidden from both the Existing and Proposed admin views and from all public surfaces). 404 if campaign_id is not currently proposed.

Related: docs/Individual_Feature/mpi-campaign-proposal-approval.md