This document describes the public API surface exposed to customers.
/api/v1
Public APIs accept:
X-API-Key) — Registered / Pro / Enterprise users generate their own Fetch, MCP, and Malware-Firewall keys self-service from My Account (see PLATFORM_API_KEYS.md).Public APIs enforce tier-based response filtering for sensitive data.
Scoring formulas, weights, and internal rationale are not exposed in the public API.
Public Swagger UI is available in debug/non-production environments only (returns 404 in production):
GET /api/docsGET /api/openapi.jsonThe 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.
| Tier | Access |
|---|---|
| FREE | Minimal fields |
| REGISTERED | Standard fields |
| PRO | Expanded fields |
| ENTERPRISE | Full fields (public plane only) |
/api/v1/graphql/api/v1/mcp/api/v1/* and /api/token-hub-registration/* (CVEs, analytics, EOL, premium package intelligence, BYOK registration, etc.)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.
| Param | Type | Notes |
|---|---|---|
q | string | CVE ID prefix or description substring |
year | int (1999–2030) | Filter by CVE year |
severity | string | CRITICAL / HIGH / MEDIUM / LOW |
kev_only | bool | Only CISA KEV CVEs |
epss_min | float (0–1) | Minimum EPSS score |
sort | string | published[:desc|:asc] or cvss[:desc|:asc]; default year-descending |
published_after | YYYY-MM-DD | Inclusive lower publish-date bound |
published_before | YYYY-MM-DD | Inclusive upper publish-date bound |
has_analysis | bool | true → only Phoenix-analyzed CVEs; false → only un-analyzed |
limit | int (1–200) | Page size (Data-Shield pagination applies) |
offset | int | Pagination offset |
Every result carries a has_analysis flag. Recipes:
GET /api/v1/cves?severity=CRITICAL&sort=published:desc&limit=10GET /api/v1/cves?published_after=YYYY-MM-DD&sort=published:descGET /api/v1/cves?severity=CRITICAL&has_analysis=true
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.
GET /api/v1/cves/{cve_id}
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”:
| Field | Minimum tier | Description |
|---|---|---|
detail_url | all tiers (flag on) | Canonical https://phxintel.security/cve.html?id=CVE-... deeplink. |
root_cause | all tiers (flag on) | {"class", "cwe_ids", "derivation"} — deterministic, CWE-derived, no LLM. See vocabulary below. |
ransomware | REGISTERED | Boolean ransomware-campaign association. |
ransomware_families | PRO | string[] named threat-actor families (from ransomware_db.json); empty array (not absent) when associated but no named family is known. |
remediation | REGISTERED | string | 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).
derivation | Meaning |
|---|---|
cwe_primary | The CVE's first-listed CWE mapped — the confident case. |
cwe_secondary | A later CWE in the list mapped, not the first. |
unmapped | No CWE mapped (or none present); class is UNKNOWN. Never a nearest-neighbour guess. |
GET /api/v1/cves/{cve_id}/attack-techniquesenable_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.
| Surface | Behaviour |
|---|---|
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-techniques | Dedicated 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}/timeline | Deliberately 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). |
| MCP | Deliberately excluded this task. No tool/resource yet — revisit once Gate 3 clears. |
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"
}
}
state | Meaning |
|---|---|
corroborated | Both methods agreed on at least one technique — techniques is non-empty. |
silent | Both methods ran; nothing agreed. A real result, not an error. |
below_quality_floor | The CVE description was too thin to attempt classification. |
unadjudicated | The 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.
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:
| Parameter | Type | Default | Bounds | Description |
|---|---|---|---|---|
limit | int | 100 | 1–500 | Maximum CVEs to return. Clamped in the service layer too, so a direct caller cannot request an unbounded page. |
offset | int | 0 | ≥ 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)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.
{ "cve_ids": ["CVE-2024-27198", "CVE-2024-27199"] }
1–1000 strings. Input is uppercased, deduplicated, and sorted before lookup.
{ "total": 2, "found": 2, "not_found": [], "items": [ { "cve_id": "CVE-2024-27198", "...": "same shape as GET /{cve_id}" } ] }
Read live from the existing batch_lookup tier feature config, not a hardcoded constant:
| Tier | limit_value | Behavior |
|---|---|---|
| Free | 0 | Disabled — any non-empty request → 413. |
| Registered | 50 | Up to 50 CVE IDs per request. |
| Pro | 500 | Up to 500 CVE IDs per request. |
| Enterprise | None | Unlimited — 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).
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.
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.
| Method | Path | Source dataset |
|---|---|---|
GET | /api/v1/analysis/cwe | cwe_analysis.json |
GET | /api/v1/analysis/cwe-top25 | cwe_top25_analysis.json |
GET | /api/v1/analysis/kev-cwe | kev_cwe_analysis.json |
GET | /api/v1/analysis/owasp-top10 | owasp_top10_analysis.json |
GET | /api/v1/analysis/reference-threat-frequencies | see 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.
| Caller | Payload |
|---|---|
| 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:
reference_threat_frequencies_public.json snapshot — byte-for-byte the same as before this feature shipped, no shaping applied, no tier awareness."no_known_fix" — the component has known CVEs but no advisory
names a fixing version, so safe_upgrade_version carries the dist-tag
directive "latest" rather than a version number (2,117 components). Nothing was
version-compared, because there was no version to compare. Treat
"latest" as a token, not a semver — compare with equality, never with a
version ordering. The field itself is still never a number, but a
separate, dated, provenance-qualified reference release
(cra_safe_upgrade_reference) now says what "latest" currently
resolves to. The original objection — that the record's latest_version
is wrong in the dangerous direction (lodash carries 4.9.0 against a real
latest of 4.17.21, so emitting it would tell a user on 4.17.20 to move backwards,
below every fix they already have), and is absent for 85% of the components in this state
— is a provenance defect rather than a property of registry data:
lodash's latest_version is source: deps.dev, while the
npm-registry-sourced value for the same package is 4.17.23.
latest_stable is populated only by the ecosystem registry
fetchers (deps.dev never sets it), so reading that field instead is itself the provenance
filter. Two invariants keep the reference safe: it is never emitted unless
strictly above the submitted version (a stale snapshot yields
"not_above_submitted", an absence with a reason, rather than a number pointing
backwards), and it always travels with cra_safe_upgrade_reference_as_of, the
artifact's own generation timestamp. A component with zero known CVEs still
returns no value at all — "no known CVEs" must never become "upgrade anyway".reference_threat_frequencies.json (fresher; carries real cross_dataset_analysis, coverage_pct, nvd_representation_*) and shapes it per caller tier, per the exposure table below (added 2026-07-24; a stricter model than the shared table above — this route never returns AccessMode.FULL on the public API, even to Enterprise/global-admin).| Caller | Exposure | percentage field |
|---|---|---|
| Anonymous | None — threat_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 only — percentage 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.
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.
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"] }
}
GET /api/v1/analysis/cwe-intelligenceAdded 2026-08-23. Gated by feature flag
enable_cwe_intelligence_endpoint(defaultfalse). Unlike the five CWE/OWASP/threat-frequency routes above (whose flag only governs payload shaping and which always return200), 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.
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:
| Caller | AccessMode | Fields 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.
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.
{
"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
}
source_provenance reports per-artifact provenance ({"name", "generated_at"}) rather
than one top-level freshness stamp, since the four underlying artifacts refresh independently. Named
source_provenance (not sources) precisely because each row in cwes also
carries its own, differently-shaped sources: List[str] field — the two are unrelated and a
shared name previously collided (fixed in the final whole-branch review, 2026-08-24).total_unique_cwes always equals len(cwes) for the response actually returned (i.e.
it reflects the shaped, tier-filtered table, not the unshaped merge).dropped_keys counts raw keys from cwe_analysis/kev_cwe/owasp
that failed CWE-id normalisation (e.g. NVD's sentinel pseudo-CWEs NVD-CWE-noinfo/NVD-CWE-Other)
and were therefore silently excluded from the merge, rather than appearing as a row. 0 in the
steady state; a nonzero value is a signal to check the source artifacts, not an error by itself.ETag is a sha256 over the shaped (post-tier-filtering) response bytes, computed
after access_mode has already been applied — never over the raw merge. Two callers at
different tiers therefore get different ETags for the same URL, which matters for the header
below.Cache-Control: private, max-age=0, must-revalidate is the load-bearing caching header here, not
Vary — this response varies by authenticated-caller tier, not by a request header a
shared/intermediary cache could key on. private stops a shared cache from storing or cross-serving
the response across tiers; must-revalidate plus the shaped ETag still let a single
client correctly 304 (via If-None-Match) against its own prior request.detail_url on GET /api/v1/analysis/cwe (Purple contract B1)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)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).
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.
{
"ecosystem": "Maven",
"name": "com.h2database:h2",
"version": "1.3.176"
}
| Field | Notes |
|---|---|
cves | Version-filtered OSV CVE/advisory records. |
vulnerabilities | Alias of cves. |
malware | Premium MPI malware summary. Raw signals, IOCs, scan IDs, and internal evidence are not exposed on the public plane. |
ps_oss_score | Derived package risk score. |
source_attribution | Sources used to build the response. |
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:
| Field | Notes |
|---|---|
ever_malicious | Boolean; true only if any version's public verdict is MALICIOUS. SUSPECT never sets this. |
first_flagged_at | Earliest 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_url | The package's own malware.html?purl= deeplink (B1). |
POST /api/v1/intel/library-versions
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.
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.
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.
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.
| Caller | Result | Body |
|---|---|---|
| No API key | 401 | {"detail": "CRA library intelligence requires an API key"} |
| Free / anonymous key | 403 | {"detail": "CRA library intelligence requires a Pro or Enterprise intelligence key"} |
Registered (api_basic) | 403 | Same gate; the exact detail may come from the shared scope validator rather than this endpoint's own string. |
Pro (API_POWER / API_INTEGRATION) | 200 | Full response as documented below. |
Enterprise (API_UNLIMITED) | 200 | Byte-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.
| From | To get | Do |
|---|---|---|
| No key | 401 → 403 | Register and issue an API key (/api-keys). |
| Free / Registered | 200 with the full CRA body | Move 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. |
| Pro | Nothing further for CRA fields | Enterprise adds no CRA field today (see above). Choose it for quota and support, not for this endpoint's shape. |
Pro / Enterprise without malware | The malware block | Purchase 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. |
{
"purls": [
"pkg:npm/lodash@4.17.20",
"not-a-purl"
]
}
purls requires 1–500 entries; each purl string is additionally
capped at 512 characters.
{
"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"}
]
}
| Invariant | Meaning |
|---|---|
component_resolved always present | An 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 version | The 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 cap | library_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 present | safe_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 not | Three 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-only | A 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 unavailable | Absence of data must never render as absence of risk. |
malware absent, not null | Present only for callers licensed for the premium block. |
| Partial failure is per-component | One malformed or unresolvable purl never fails the rest of the batch. |
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.
GET /api/v1/intel/*
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.
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/intel/entities | List the 8 queryable entity names. |
GET | /api/v1/intel/vocabularies | Published 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 /). |
| Entity | ID form example |
|---|---|
cve | CVE-2024-27198 |
cwe | CWE-89 |
vendor | microsoft |
product | microsoft:windows (vendor:product) |
library | pkg:npm/express |
library_version | pkg:npm/express@4.17.1 |
malware | pkg:npm/express@4.17.1 (same purl form) |
cpe | cpe:2.3:a:microsoft:windows:... |
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"
}
}
deterministic holds only enumerated classes and stable identifiers — never free text or a raw percentage. Its arrays are always sorted ascending by whichever stable ID field is present (id/signal_id/malware_signal_id/cve_id/cwe_id/purl/cpe_uri/technique_id). The same pass orders the expanded block's arrays (determinism rule D5), which is why expanded.malware.malware_signals[] is ordered by malware_signal_id. One documented exception: expanded.campaign.campaigns[] rows carry none of those stable-ID keys, so that array is ordered by its producer, ascending by (date, name) — still total, still reproducible, but not the stable-ID rule. (It was returned in artifact order, i.e. unordered, until 2026-08-14.) advisory is exempt from D5 entirely: LLM output is not reproducible and ordering it would imply that it is.advisory is null for every entity except malware in this release — classified ENTERPRISE. For malware, Plan D (2026-07-26) now populates it — see "Advisory block opt-in" below. Every other entity still always emits null.provenance.redaction_profile names the shaping pass (free/registered/pro/enterprise here; pai on the PAI mirror).{"entity": "...", "total": N, "items": [...]}.| Class | Range |
|---|---|
VERY_HIGH | >= 0.90 |
HIGH | 0.75–0.8999 |
MODERATE | 0.50–0.7499 |
LOW | 0.25–0.4999 |
VERY_LOW | < 0.25 |
UNKNOWN | not 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").
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 category | Minimum 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 identity | Public |
| CPE / vendor / product mapping fields | Registered |
| PS-OSS band | Pro |
| MITRE tactics (coarse aggregate) | Pro |
| MPI signal IDs, MITRE technique IDs (fine-grained), signal chains | Enterprise |
| IOC type lists | Pro |
| 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.
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.
?include=advisory (Plan D)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
include accepts a comma-separated list or a repeated parameter. Since
Plan D it also accepts the licence-gated expanded tokens documented below; an unrecognised token
is a 400 carrying the full valid list, not a silent no-op. The
advisory token itself is silently ignored for every entity other than
malware.enterprise
and the caller passed ?include=advisory. A Pro-and-below caller who passes it
gets an unchanged response — advisory stays null, no error. A non-admin
Enterprise caller is additionally narrowed to a terminal, no-pending-review verdict state
(CONFIRMED_MALICIOUS/CLEAN/DISPUTED_FP/PUBLISHED, or a
compromise-intel-only confirmed-malicious record) — for a package still under active review
(SUSPECT/AWAITING_REVIEW/LLM_ANALYSIS/INCONCLUSIVE), the
envelope still resolves (200) but advisory comes back null, matching
/reasoning's own narrowing below. Global admins are exempt.GET /internal/v1/intel/malware/{entity_id}?include=advisory, see
PAI_INTERNAL_API.html): honored unconditionally whenever
the caller passes it — no additional tier check, and no terminal-verdict narrowing
either, since PAI access is already this platform's highest trust boundary. (The terminal-verdict
narrowing is REST-only; PAI is exempted via a signal distinct from allow_clean, so its own
separately-decided allow_clean boundary below stays untouched — see
PAI_INTERNAL_API.html for the full mechanism.)allow_clean access boundary already governing the rest of
the malware resolver — a caller who cannot resolve the base envelope for an unconfirmed/under-review
package never reaches the advisory-building logic at all, regardless of ?include=advisory. The
terminal-verdict narrowing above is a second, independent check layered on top of this one (it only ever
narrows an already-allow_clean-visible row's advisory field, never the row's own
visibility).{"analyst": {"verdict", "confidence_class", "reasoning"}, "judge": {"verdict",
"reasoning"}, "reproducible": false} — the same llm shape the
/reasoning route below returns (both built by build_reasoning_view()), plus a
reproducible: false marker so a deterministic/code-scanning consumer can hard-fail on this block
rather than diff non-reproducible LLM prose between snapshots. reproducible is classified
ENTERPRISE in data_shield.py, same as its siblings.?include= (Plan B + Plan D)Added on this public surface 2026-08-14 (Plan D). Requires
enable_intel_query_surfaceandenable_global_intel_license, both defaultfalse. 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:
| Axis | Decided by | Controls |
|---|---|---|
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.
| Block | Free | Registered | Pro | Enterprise |
|---|---|---|---|---|
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 |
malware | verdict | — | + malware_signals[] (with malware_signal_id), mitre,
iocs, rollup |
+ chains, phx_neural_score |
provenance.block_sources / block_freshness / block_errors /
block_notes | visible | — | — | — |
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.
| Condition | Behaviour |
|---|---|
No include | Byte-identical to the pre-Plan-D response. |
| Unknown token | 400, detail naming the unknown token(s) and
listing every valid token. |
enable_intel_query_surface off | 404 — 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 bundle | 403
{"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 exhausted | 429. 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 token | 403 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.
intel_get with includeThe intel_get MCP tool accepts the same include tokens, validated by the same
registry helper. Two differences from REST, both deliberate:
allow_clean, at any tier including
enterprise. An MCP caller is identified only by a tier string — no admin flag, no
CognitoUser, no PAI credential — which is not the verified higher-trust signal that gate
requires.license_required: true and a note appended to summary_markdown, rather
than an error.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.
phoenix-cli intel get --includephoenix-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.)
Two deliberate departures from the original design doc — intentional, not oversights:
products is PUBLIC, not REGISTERED as originally drafted — VendorResolver's product-name list reuses the already-PUBLIC "products" key used by the live EOL feature. The classification map is flat and global (last-key-wins), so a conflicting second entry would have silently downgraded EOL's existing classification. A future fix for Registered-only visibility should rename the envelope key, not edit the shared classification.kev_cve_count is ENTERPRISE, stricter than the design doc's "CVE/CWE core = all tiers" expectation — intentional, matching the established convention (see the CWE/OWASP/threat-frequency tiered API above) that raw proprietary volume/count numbers are Enterprise-gated even when adjacent IDs/ranks/labels stay public.This surface is flagged off everywhere today. These are tracked, open items — not defects — that gate enabling enable_intel_query_surface:
/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./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.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.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.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):
GET /api/v1/intel/{entity} (search) now enforces the same per-user/API-key daily search feature quota /api/v1/cves/search does, via the same _enforce_feature pattern, for authenticated and API-key callers.limit is now clamped to the caller's real tier cap (get_policy_engine().enforce_pagination) before reaching the resolver layer, instead of only the route's static 1-200 FastAPI bound; offset is clamped to the same global OFFSET_MAX_WINDOW every other public route uses (not a tier-specific cap — identical to /api/v1/cves/search's own behavior).anyio.to_thread.run_sync), matching /api/v1/cves/search's own offload — some resolvers (CveResolver in particular) run unindexed O(N) scans and must never block the event loop.CveResolver.search() no longer re-resolves every hit through the single-entity get() path (which re-scanned the in-memory NVD dataset twice per row); it builds each result directly from the search_cves() row. Trade-off: search results no longer carry cwe_ids, modified_at, cpe_uris, cvss_vector, or kev_added_at (the first three were never present on a search_cves() row; cvss_vector/kev_added_at WERE previously populated on search results and are now null there too) — follow up with GET /api/v1/intel/cve/{id} for the full deep-dive, which still populates all five.max_page_size, and it now offloads the same way.offset is now honored by CweResolver/CpeResolver search too (previously silently ignored/hardcoded to 0) — paginating past page 1 for those entities no longer returns page 1 again forever. The other 5 entities remain exact-lookup searches that ignore offset regardless of its value (see item 6 above)./api/v1/research/*
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.
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/research | Trigger 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/research | List 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.
kind | Target | Example |
|---|---|---|
PACKAGE_SCAN | Versioned purl | pkg:npm/express@4.17.1 |
CVE_ANALYSIS | CVE id | CVE-2024-27198 |
PACKAGE_HISTORY_REFRESH | Purl 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.
GET /api/v1/user/me/tierAdded 2026-08-17. This route is public —
/api/v1/useris in_PUBLIC_API_PREFIXES(main.py:632) and the path carries no/adminsegment. Gated by feature flagenable_global_intel_license(existing, defaultfalse). 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):
global_intel row on the caller's own
product_entitlements, honoring expires_at — an expired
personal bundle grants nothing. Gated on enable_global_intel_license alone.
global-admin or
admin group holds the bundle at enterprise with no entitlement
row — the same two groups _resolve_user_tier_name already resolves to
enterprise on the account-tier plane. Gated on
enable_global_intel_license like every other path: an operator role is a reason
to hold the bundle, never a reason to switch the feature on. The grant runs one way
only — role grants the entitlement; holding the bundle still confers no admin
authority anywhere (allow_clean remains is_global_admin on public
REST, unset on PAI, and is not referenced in MCP).
tenant_product_entitlements / team_product_overrides chain via
org_license_resolver.resolve_bundle_tier — deliberately not
effective_entitlement(..., 'global_intel', ...), which falls back to the org's
plan_tier and would grant the bundle to every member of every org that never
bought it. resolve_bundle_tier passes no plan floor, so an org with no bundle
row resolves to no licence. Resolution failure here logs and yields no bundle rather than
500ing the page.
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.
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.
"user" — expiry reflects the caller's own expires_at;
state is active, expiring_soon (within 30 days),
expired, or unknown (unparseable date)."org" — the org/team grant won. expires_at is
null and state is active, because an org grant cannot
lapse. Do not fall back to the user-level dates here; they are not the
licence in effect."admin" — the reported tier came from the platform-admin role grant.
expires_at is null and state is active
for the same reason as "org": a role does not lapse.
Attribution follows whichever scope actually produced the reported tier,
checked user → org → role. A sold entitlement that matches the role grant's
enterprise therefore keeps the attribution and its expiry warning. A
sold entitlement below it does not: an admin whose personal pro
bundle is lapsing still resolves to enterprise by role, so the scope is
"admin" and no lapse is reported. That is deliberate — dating an
enterprise tier from a lapsing pro row is exactly the false-lapse
mismatch expiry.scope exists to prevent. The paid row's own date is not surfaced
here; a single (tier, expiry) pair cannot describe two licences.
Clients should key any expiry UI off expiry.scope together with
expiry.state, never off state alone.
{
"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.
/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.
| Method | Path | Purpose |
|---|---|---|
POST | /api/token-hub-registration/byok/onboarding | Register tenant BYOK in one Token Hub onboarding call. |
POST | /api/token-hub-registration/byok/api-key | Register or replace a BYOK API key. |
POST | /api/token-hub-registration/byok/keys/{platform}/{vendor}/revoke | Revoke an active BYOK key. |
GET | /api/token-hub-registration/byok/keys | List BYOK key summaries; secretRef is never returned. |
Accepted platform values: blue, purple,
green. Accepted vendor values: openai,
anthropic, gemini.
{
"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.
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.
| Param | Type | Description |
|---|---|---|
provider | csv string | Filter by provider(s): anthropic,openai,google,meta,xai,aisle |
program | csv string | Filter by program: glasswing,quiltworks,madbugs |
min_confidence_tier | int (1–3) | Default 1 |
min_cvss | float | Default none |
since | ISO date | Filter discovered_date >= since |
cursor | string | Pagination cursor |
limit | int | Default 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.
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/evaluateAuth:
X-API-Key header with a phx_fw_* Malware Firewall key (any plan), or a Cognito JWT/cookie session at Pro tier or above. Resolved via the shared _resolve_firewall_caller dependency.pro/enterprise, or tier registered with a valid phx_fw_* key presented (the firewall freemium floor). A plain registered-tier JWT session without a key gets 403 ("Firewall evaluate requires a Malware Firewall API key (phx_fw_*) or Pro tier").registered 30/min, pro 100/min, enterprise 500/min — 429 on exceed.phx_fw_* key): 1000 requests/calendar month — 429 on exceed. When enable_scf_seat_licensing is on, this legacy per-key cap is skipped and the org-pooled seat/call quota (see GET /api/v1/firewall/license below) is authoritative instead.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
}
| Field | Type | Notes |
|---|---|---|
results[] | array | One entry per requested package, in request order |
results[].action | string | allow | 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[].mpi | object | {signals[], confidence, threat_type, mitre_techniques[], iocs{}} — MPI context for the package |
results[].ps_oss_score | int | null | Proprietary PS-OSS trust score, when available |
results[].package_age_hours | float | null | Package publish age, when available |
results[].trust_context | object | Additional maintainer/publish trust signals |
results[].notifications | array[string] | Deduplicated notification targets (e.g. slack:#channel) collected from matching rules |
approval_ids | array[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_at | string (ISO 8601) | Timestamp the evaluation completed |
cache_ttl_seconds | int | Always 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
Flag: enable_scf_seat_licensing (default true in the current rollout). These endpoints return 404 when the flag is off.
GET /api/v1/firewall/licenseReturns 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 }
}
registered, pro, and enterprise. Legacy standard licenses normalize to pro; legacy free licenses normalize to registered.pooled = true only for Enterprise. Registered and Pro enforce per-seat monthly call and endpoint caps via per_seat_call_quota and per_seat_endpoint_quota.endpoint_quota = 0 / call_quota = 0 means the org is not pooled on that dimension. For Enterprise, 0 still means unlimited where endpoints_per_seat is 0.POST /api/v1/firewall/license/seat-requestSubmit 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
/api/v1/admin/*) and PAI/internal (/internal/v1/*) endpoints are excluded from the public schema.
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-tokensIssues 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.
enable_firewall_enrollment_token_issuance (SITE_CONFIG, default off) or enable_firewall_endpoint_mode is off, so the feature's existence is not leaked.api_firewall entitlement.Request body:
{
"ttl_hours": 168,
"name": "laptop-enrollment"
}
| Field | Type | Default | Notes |
|---|---|---|---|
ttl_hours | int (1–720) | 168 | Token validity window before expiry |
name | string, optional | — | Max 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
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.
| Method | Path | Who |
|---|---|---|
| POST | /api/v1/firewall/exceptions | any registered user |
| GET | /api/v1/firewall/exceptions | own (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}/approve | decider only (see Decision authority) |
| POST | /api/v1/firewall/exceptions/{id}/reject | decider only |
| POST | /api/v1/firewall/exceptions/bulk-preview | decider (read-only, server-computed) |
| POST | /api/v1/firewall/exceptions/bulk-approve | decider (team or org) |
| POST | /api/v1/firewall/exceptions/bulk-reject | decider (team or org) |
POST /api/v1/firewall/exceptionsRequest body:
| Field | Type | Notes |
|---|---|---|
package_name | string | required |
package_version, ecosystem | string, optional | — |
target_kind | string | package | agent_skill | openvsx, default package |
scope | string | user | team | org (endpoint is rejected — admin-authored only, not requestable here) |
scope_ref | string, optional | ignored — server-derived; see below |
tenant_id | string, optional | ignored — always the caller's resolved SCF tenant |
reason | string | required, non-blank, max 2000 chars |
audit_log_id | string, optional | links the request back to the blocking /audit row |
requested_expiry_days | int, optional | default 90; 0 = permanent |
scope_ref resolution (client input discarded in every case):
Requested scope | Stored scope_ref |
|---|---|
user | the resolved requester's own id, always |
org | null |
team | the requester's own team id — 403 if the client-supplied team is not one they belong to |
endpoint | 403 — 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/exceptionsQuery 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 / .../rejectApprove 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:
| Decider | user-scope | team-scope | org-scope |
|---|---|---|---|
| Tenant admin / platform admin | yes | yes | yes |
| Team admin | yes, if the requester is on their team | yes, for their own team only | no — 403, escalate to an org admin |
| Plain member | no — 403 (no self-approval) | no | no |
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.
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:
| Field | Non-privileged (own rows) | Tenant admin / platform admin |
|---|---|---|
log_id, package_name, package_version, ecosystem, action_taken, target_kind, created_at | shown | shown |
reason_category (derived: Malware indicators | Vulnerability | Package reputation | Policy rule) | shown | shown |
mpi_signals, matching_rules, mpi_confidence, ps_oss_score, notifications_sent, build_context, rule_id, user_id | omitted | shown |
Related: docs/plans/2026-07-18-firewall-package-exception-requests-design.md
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.
| Method | Path | Who |
|---|---|---|
| GET | /api/v1/firewall/allowlist | tenant admin / platform admin |
| POST | /api/v1/firewall/allowlist | tenant 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/export | tenant admin / platform admin |
| POST | /api/v1/firewall/allowlist/import | tenant admin / platform admin, tier-gated per scope, per-row |
| GET | /api/v1/firewall/allowlist/version | agent (device_id) or interactive caller (as_user=true) |
| GET | /api/v1/firewall/allowlist/effective | agent (device_id) or interactive caller (as_user=true) |
| Scope | Tier required | Cap |
|---|---|---|
org, user, endpoint | Enterprise only | — |
team | Pro or Enterprise | 200 libraries per team |
GET /api/v1/firewall/allowlistQuery filters: scope, scope_ref, limit (default 50, max 200), offset. Returns {"items": [...], "total": N}, tenant-scoped.
POST /api/v1/firewall/allowlistRequest body:
| Field | Type | Notes |
|---|---|---|
package_name | string | required |
version | string, optional | null/* = all versions |
ecosystem | string | required when verdict is allow (400 if null/blank/whitespace); optional for verdict: "block" |
target_kind | string | package | agent_skill | openvsx, default package |
scope | string | user | team | org | endpoint |
scope_ref | string, optional | required 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 |
verdict | string | allow | block, default allow |
overridable | bool | default true; false marks a non-bypassable security floor |
expires_days | int, optional | default permanent; 0 = permanent |
malicious_override_ack | bool | default false — see malicious linkage below |
Guard order: flag → is_tenant_admin or platform _is_admin_user → scope_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:
status: "exact") malicious linkage and malicious_override_ack was not set — response body {"message": ..., "linkage": {"status", "signals", "sources"}}. Retry with the ack flag once reviewed; the written row then carries malicious_ack_by/malicious_ack_at.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/importAdded 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:
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./rules/import has no such cap.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.
format=json → {"version": 1, "exported_at": <ISO-8601 UTC>, "count": N, "entries": [...]}, each entry {package_name, version, ecosystem, target_kind, scope, scope_ref, verdict, overridable, expires_at}.format=csv → text/csv with Content-Disposition: attachment, columns package_name, version, ecosystem, target_kind, scope, scope_ref, verdict, overridable, expires_at. Values are CSV-injection sanitized (leading = + - @ \t \r \n prefixed with '), same pattern as GET /api/v1/analytics/admin/export.POST /api/v1/firewall/allowlist/import
Request body:
| Field | Type | Notes |
|---|---|---|
format | string | json | csv, default json |
entries | array of objects | required when format=json — same row shape as export's entries |
content | string | required 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.
imported — rows successfully written.failed / errors — rows rejected for any reason other than the malicious-linkage ack gate (validation, tier gate, 200-cap, an existing non-overridable conflict, a wildcard-version allow over a package with confirmed-malicious versions, or a genuine DB error) — never silently dropped; failed is the count, errors the per-row detail.flagged — rows with a confirmed (status: "exact") malicious linkage and no malicious_override_ack — not written, kept separate from errors so a caller can tell "needs review" apart from "outright rejected". An "indeterminate" linkage proceeds (fail-open, design spec §11.5.3) and is recorded as allowlist.malicious_indeterminate_pass, same as POST /allowlist.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/effectiveAdded 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):
| Param | Type | Meaning |
|---|---|---|
device_id | string (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_user | bool | Resolve 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.
items — one merged entry per (package_name, ecosystem, target_kind, version) identity across every scope in the resolved cascade, reduced via the real resolve_allowlist precedence function (never reimplemented). This agrees with POST /evaluate only for that literal version — /evaluate always resolves exactly one specific package+version pair and can union a wildcard-version candidate into that one version's group; /effective has no single "version in question" to union against (its whole purpose is the full merged policy across every package the cascade has any entry for), so it deliberately does not attempt that union. This is a structural difference from /evaluate, not a bug — see floor_blocks below for the case it affects.floor_blocks — non-overridable (overridable: false) block rows authored at a wildcard/null version (the security floor — see resolve_allowlist's docstring in backend/app/services/firewall_allowlist.py). A floor like this blocks every version of the named package, regardless of what a more specific scope or version says. It is deliberately excluded from items (folding it in there would misrepresent it as applying only to a literal "*" version) and surfaced here instead. Each entry carries at least package_name, ecosystem, scope, scope_ref, verdict: "block", overridable: false. A caller MUST apply each floor_blocks entry independently as "this package is blocked at every version" — including any version that items shows as allow.Related: docs/plans/2026-07-18-firewall-tiered-allowlist-plan-B.md
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:
| Value | Meaning |
|---|---|
PHXMINT | MPI pipeline (LLM+judge) assessment |
OSSF | OpenSSF Malicious Packages feed |
OSM | Open Source Malware feed |
VULNDB | VulnDB-sourced compromise intel |
PHOENIX_RESEARCH | Phoenix-authored research entry |
MANUAL_VERIFIED | analyst-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.
GET /api/v1/malware/list and GET /api/v1/malware/stats/*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.
mpi:pub:*), so operator changes are visible to the next visitor without waiting for the TTL. The staleness window effectively applies only to pipeline-discovered changes.?cursor=...) and free-text searches (?q=...) are never cached — always live.Cache-Control/ETag headers are emitted — responses vary by authentication cookie, so caching is server-side only.GET /api/v1/malware/{purl} and GET /api/v1/malware/package?purl= — public package detail (Purple contract B5)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.
/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:
| Key | Meaning |
|---|---|
mpi_verdict_malicious | Production MPI consensus dossier verdict is malicious |
mpi_pipeline_confirmed | MPI pipeline (LLM+judge) verdict is CONFIRMED_MALICIOUS |
compromised_intel_row | A compromised_package_intel row exists for this purl (any source) |
feed_ossf | compromised_package_intel.source == 'ossf' for a matched row (or the legacy mpi_scan_results.feed_ossf column) |
feed_osm | compromised_package_intel.source == 'osm' for a matched row (or the legacy feed_osm column) |
feed_safechain | compromised_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_malicious | True 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 OR — compromised_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. |
The versions[] timeline array in malware API responses uses a closed, 3-value verdict vocabulary:
| Verdict | Meaning |
|---|---|
CLEAN | The package version is clean: no malware signals detected. |
MALICIOUS | The package version is confirmed malicious. |
SUSPECT | The 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": "..." }
}
}
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:
GET /api/v1/intel/malware/{id}?include=advisory, see "Advisory block opt-in" above) are both
built by the same shared build_reasoning_view() in
app/services/mpi_reasoning.py — those two planes genuinely cannot disagree.GET /api/v1/malware/{purl}'s
llm_analysis) is built by a separate, independent function,
_build_llm_analysis_block() in backend/app/routers/malware_public.py. It shares the
same underlying confidence_class() helper but has its own field construction with real
divergences: no reasoning-text coercion, no verdict uppercasing, and — most
importantly — the dossier alone retains the legacy numeric confidence field
(see the migration note below). The other two planes never emit it.| Surface | Builder | Confidence 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.
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":
GET /{purl}/reasoning) returns a flat 403 for any
caller below Enterprise before build_reasoning_view() is ever invoked — a
Pro/Registered/Free caller cannot observe any row of this table, including the Pro/Registered/Free rows,
because the function is never called for them.?include=advisory, see "Advisory block
opt-in" above) hard-codes build_reasoning_view(row, tier="enterprise") regardless of the
caller's actual tier, then extracts only the llm sub-block —
verdict, phx_neural, signals, mitre, iocs,
and chains are computed internally but never returned to any caller on this plane, at any tier.
Enterprise-tier gating for this plane happens one layer up, in
backend/app/routers/intel_query.py (profile == "enterprise"), before the resolver
is ever called for a non-Enterprise caller.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).
| Tier | verdict/phx_neural | signals | mitre | iocs | chains | llm |
|---|---|---|---|---|---|---|
| Free / Registered | band only | null | null | null | null | null |
| Pro | band only | category counts only (no signal IDs) | tactics only | types only | null | null |
| Enterprise / PAI — the only reachable row | full, + numeric score | per-signal items + total | tactics + technique_ids | types | pair matches + multiplier | analyst + 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.
confidence → confidence_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.
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.
Server-side sequential batch worker for draining Kanban columns. Admin-only (requires MPI pipeline enabled).
POST /api/v1/malware-intel/queue/bulk-processSchedule a bulk job; returns immediately with a job_id.
| Field | Type | Default | Description |
|---|---|---|---|
kanban_status | string | "awaiting_review" | Source column |
max_packages | int | 50 | Pipeline cap 1 000; approve_malicious cap 100 000 |
mode | string | "pipeline" | pipeline = analyst+judge; approve_malicious = confirm by score |
min_score | int|null | null | Only include packages with heuristic_score ≥ min_score |
batch_size | int | 50 | Items per batch (approve_malicious mode) |
pause_seconds | float | 0.0 | Pause between batches |
pipeline_steps | int | 2 | 1=analyst, 2=analyst+judge, 3=+verifier |
retry_inconsistent | bool | true | Re-run pair on analyst/judge disagreement |
reason | string | "" | 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}/statusPoll 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
/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|proposedview 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}/approveMaterializes 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}/rejectSets 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