openapi: 3.0.3
info:
  title: MalwareAnalyser Public API
  version: "1.1.0"
  description: >
    Public REST API for MalwareAnalyser — unified static/dynamic/interactive malware analysis
    and a threat-intelligence graph. Everything the UI can do is available here.
    Authenticate with an API key (X-Api-Key) or a Bearer JWT.
servers:
  - url: https://malwareanalyzer.com
    description: Production
  - url: http://localhost:4015
    description: Local analysis service
security:
  - ApiKeyAuth: []
  - BearerAuth: []
webhooks:
  scanCompleted:
    post:
      summary: Scan-complete webhook (delivered when an analysis finishes)
      description: >
        POSTed to your configured sink URL when a submission's report is finalized. The body is
        HMAC-SHA256 signed with your webhook secret in the X-ThreatLens-Signature header.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, enum: [scan.completed] }
                taskId: { type: string }
                tenantId: { type: string }
                contentHash: { type: string }
                verdict: { type: string, enum: [clean, suspicious, malicious, unknown] }
                score: { type: integer }
                family: { type: string, nullable: true }
                visibility: { type: string, enum: [public, private] }
                reportUrl: { type: string }
                at: { type: string, format: date-time }
      responses:
        "2XX": { description: Acknowledged }
paths:
  /v1/submit:
    post:
      summary: Submit a file, URL, or hash for analysis
      operationId: submit
      # Verified anonymous against production 2026-08-19. Without this the global `security`
      # requirement above applies, and every generated client and reader is told to obtain a
      # key for an endpoint that needs none. Anonymous is still METERED (1,000 lookups/day per
      # IP); `security: []` means no credential is required, not that the call is unlimited.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubmitRequest"
      responses:
        "202":
          description: Submission admitted asynchronously with queue position, ETA, polling, and stream URLs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubmitResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/files/bulk:
    post:
      summary: Bulk-submit up to 200 samples (hash/file/url) in one call
      operationId: bulkSubmit
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                items:
                  type: array
                  maxItems: 200
                  items:
                    type: object
                    properties:
                      fileBase64: { type: string }
                      hash: { type: string }
                      url: { type: string }
                      fileName: { type: string }
                      visibility: { type: string, enum: [public, private] }
                      tlp: { type: string, enum: [clear, green, amber, red] }
      responses:
        "202":
          description: Per-item submission results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        ok: { type: boolean }
                        taskId: { type: string }
                        contentHash: { type: string }
                        error: { type: string }
                  meta:
                    type: object
                    properties:
                      submitted: { type: integer }
                      total: { type: integer }
  /v1/files/{hash}/attribution:
    get:
      summary: Confidence-gated family/actor attribution with cited evidence (under-claims)
      operationId: getAttribution
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: threshold, in: query, required: false, schema: { type: number, default: 0.6 } }
      responses:
        "200":
          description: Attribution with candidates + evidence
          content:
            application/json:
              schema:
                type: object
                properties:
                  attributed: { type: boolean }
                  family: { type: string, nullable: true }
                  actor: { type: string, nullable: true }
                  confidence: { type: number }
                  threshold: { type: number }
  /v1/files/{hash}/label:
    post:
      summary: Submit an analyst family/verdict label (ML feedback loop)
      operationId: labelSample
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, enum: [malicious, clean] }
                family: { type: string }
      responses:
        "200": { description: Label recorded }
  /v1/files/{hash}/export/misp:
    get:
      summary: Export the report as a MISP Event (or push with ?push=1)
      operationId: exportMisp
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: push, in: query, required: false, schema: { type: string, enum: ["0", "1"] } }
      responses:
        "200": { description: MISP Event JSON (or push result) }
  /v1/files/{hash}/export/thehive:
    get:
      summary: Export the report as a TheHive Alert (or push with ?push=1)
      operationId: exportTheHive
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: push, in: query, required: false, schema: { type: string, enum: ["0", "1"] } }
      responses:
        "200": { description: TheHive Alert JSON (or push result) }
  /v1/files/{hash}/comments:
    get:
      summary: List comments + vote tally + tags for a report
      operationId: getComments
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Comments, votes, tags } }
    post:
      summary: Add a comment to a report
      operationId: addComment
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { body: { type: string } } } } }
      responses: { "200": { description: Created comment } }
  /v1/files/{hash}/vote:
    post:
      summary: Vote malicious/harmless on a report
      operationId: voteReport
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { vote: { type: string, enum: [malicious, harmless] } } } } }
      responses: { "200": { description: Updated tally } }
  /v1/files/{hash}/tags:
    post:
      summary: Add an analyst tag to a report
      operationId: addTag
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { tag: { type: string } } } } }
      responses: { "200": { description: Updated tags } }
  /v1/files/{hash}/report.pdf:
    get:
      summary: Download the report as a PDF
      operationId: reportPdf
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: PDF report
          content: { application/pdf: { schema: { type: string, format: binary } } }
  /v1/files/{hashA}/diff/{hashB}:
    get:
      summary: Structural diff of two reports (verdict, imports, IOCs, capa, ATT&CK, similarity)
      operationId: diffReports
      parameters:
        - { name: hashA, in: path, required: true, schema: { type: string } }
        - { name: hashB, in: path, required: true, schema: { type: string } }
      responses: { "200": { description: Report diff } }
  /v1/campaigns/{id}:
    get:
      summary: Campaign graph (samples + shared infra/config/certs) as nodes + edges
      operationId: getCampaign
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: depth, in: query, required: false, schema: { type: integer, default: 2 } }
      responses:
        "200": { description: Campaign subgraph }
  /v1/ml/models:
    get:
      summary: List ML triage model versions + metrics (and the active model)
      operationId: listMlModels
      responses:
        "200": { description: Model registry }
  /v1/audit:
    get:
      summary: Tenant-scoped analysis + analyst-action audit trail (exportable)
      operationId: getAudit
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, default: 200 } }
      responses:
        "200": { description: Audit events }
  /v1/tasks/{taskId}:
    get:
      summary: Get task status
      operationId: getTask
      parameters:
        - name: taskId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Task status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
  /v1/firehose/recent:
    get:
      security: []
      summary: Recent public source and user tasks with queue/runtime state
      operationId: firehoseRecent
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        "200":
          description: Public firehose snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  tasks: { type: array, items: { $ref: "#/components/schemas/Task" } }
                  running: { type: integer }
                  maxRunning: { type: integer, maximum: 50 }
  /v1/reports/{hash}:
    get:
      summary: Get a full report by content hash (public samples need no credential)
      operationId: getReport
      # Verified anonymous against production 2026-08-19. Without this the global `security`
      # requirement above applies, and every generated client and reader is told to obtain a
      # key for an endpoint that needs none. Anonymous is still METERED (1,000 lookups/day per
      # IP); `security: []` means no credential is required, not that the call is unlimited.
      security: []
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Report" }
        "404": { description: Not found }
  /v1/public/reports/{hash}:
    get:
      summary: Get a public (redacted) report — cacheable, no auth required
      operationId: getPublicReport
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Public report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Report" }
  /v1/reputation/{indicator}:
    get:
      summary: Reputation lookup for a hash/URL/domain/IP (highest-QPS surface)
      operationId: reputation
      parameters:
        - { name: indicator, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Reputation record
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Reputation" }
  /v1/graph/pivot/{nodeId}:
    get:
      summary: Pivot the threat graph from a node (accepts any IOC form)
      description: >-
        `nodeId` may be a typed node id (`sample:<sha256>`, `domain:<name>`, `ip:<addr>`, ...) or
        ANY bare IOC: a sha256/md5/sha1 hash (md5/sha1 resolve to their sha256 sample), a domain,
        an IP, or a URL (falls back to the URL's domain node). Defanged input is refanged.
        Billing: a resolved pivot counts as one investigation action against the free daily
        allowance, then spends credits; the response carries a `billing` envelope and
        X-Graph-Allowance-* headers.
      operationId: graphPivot
      parameters:
        - { name: nodeId, in: path, required: true, schema: { type: string } }
        - { name: depth, in: query, required: false, schema: { type: integer, default: 1, maximum: 3 } }
      responses:
        "200":
          description: Subgraph
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GraphPivot" }
        "402": { description: Past the free daily allowance with an empty credit balance }
        "429": { description: Anonymous caller past the free daily allowance (sign in to continue) }
  /v1/graph/pivot:
    post:
      summary: Batch pivot the threat graph from many IOCs at once (investigation workbench)
      description: >-
        Billing: each seed counts as one investigation action against the free daily allowance,
        then spends credits (`graph_pivot` price). The response carries a `billing` envelope
        (allowance remaining, credits charged/remaining) and X-Graph-Allowance-* headers.
      operationId: graphPivotBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [seeds]
              properties:
                seeds: { type: array, items: { type: string } }
                depth: { type: integer, default: 1, maximum: 3 }
      responses:
        "200": { description: Unioned subgraph plus any unresolved seeds and a billing envelope }
        "402": { description: Past the free daily allowance with an empty credit balance }
        "429": { description: Anonymous caller past the free daily allowance (sign in to continue) }
  /v1/graph/enrich:
    post:
      summary: Enrich graph nodes with native intel plus your BYO providers (VirusTotal/Shodan/AbuseIPDB/urlscan/OTX/GreyNoise/Censys/ThreatFox/HybridAnalysis/MalwareBazaar)
      description: >-
        Billing: NATIVE enrichment counts one investigation action per node against the free daily
        allowance, then spends credits (`graph_enrich` price). BYO-key providers are never
        credit-charged (you supply the API key). The response carries a `billing` envelope.
      operationId: enrichNodes
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nodes]
              properties:
                nodes:
                  type: array
                  items:
                    type: object
                    required: [value]
                    properties:
                      id: { type: string }
                      type: { type: string }
                      value: { type: string }
                providers: { type: array, items: { type: string } }
      responses:
        "200": { description: Overlay nodes/edges plus per-node per-provider results and a billing envelope }
        "402": { description: Past the free daily allowance with an empty credit balance }
        "429": { description: Past the free daily allowance (anonymous) or provider rate limit }
  /v1/enrichment/providers:
    get: { summary: List enrichment providers and which you have configured a key for, operationId: enrichmentProviders, responses: { "200": { description: Provider catalog } } }
  /v1/extract/iocs:
    post:
      summary: Extract IOCs from an uploaded document (PDF/JSON/CSV/STIX/MISP/eml/archive/binary)
      operationId: extractIocs
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileBase64]
              properties:
                fileBase64: { type: string }
                fileName: { type: string }
      responses:
        "200": { description: Extracted, typed, de-noised IOCs }
  /v1/settings/enrichment-keys:
    get: { summary: List your stored BYO enrichment keys (masked; never returns the key), operationId: listEnrichmentKeys, responses: { "200": { description: Keys } } }
  /v1/settings/enrichment-keys/{provider}:
    put:
      summary: Store a BYO enrichment API key (encrypted at rest)
      operationId: setEnrichmentKey
      parameters: [{ name: provider, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [apiKey], properties: { apiKey: { type: string } } }
      responses:
        "201": { description: Saved (masked) }
    delete:
      summary: Delete a stored BYO enrichment key
      operationId: deleteEnrichmentKey
      parameters: [{ name: provider, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: Deleted }
  /v1/investigations:
    get: { summary: List your saved investigation cases, operationId: listInvestigations, responses: { "200": { description: Investigations } } }
    post:
      summary: Create a saved investigation case
      operationId: createInvestigation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string }
                visibility: { type: string, enum: [public, private] }
                seeds: { type: array, items: { type: string } }
                graph: { type: object }
      responses:
        "201": { description: Created }
  /v1/investigations/{id}:
    get: { summary: Fetch a saved investigation case, operationId: getInvestigation, parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { "200": { description: Investigation } } }
    patch:
      summary: Update a saved investigation case (name/visibility/seeds/graph snapshot)
      operationId: updateInvestigation
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Updated }
    delete: { summary: Delete a saved investigation case, operationId: deleteInvestigation, parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { "200": { description: Deleted } } }
  /v1/investigations/{id}/export/{fmt}:
    get:
      summary: Export an investigation as an image, PDF, or CTI format
      description: >-
        Billing: pdf/stix/misp are premium exports and spend credits (`graph_export` price;
        sign-in required). png/svg/csv/graphml/json stay free.
      operationId: exportInvestigation
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: fmt, in: path, required: true, schema: { type: string, enum: [png, svg, pdf, stix, misp, csv, graphml, json] } }
      responses:
        "200": { description: Export artifact (image/PDF/JSON/CSV/XML) }
        "402": { description: Premium format with an empty credit balance, or anonymous caller }
  # ---- Retention engine: watches (alert-on-new-match) + alert inbox + standing hunts ----
  /v1/watches:
    get:
      summary: List your watches/subscriptions
      operationId: listWatches
      responses: { "200": { description: "Envelope { data: Watch[] }" } }
    post:
      summary: Watch an entity so a newly analyzed match alerts you
      description: >-
        Subscribe to a family/actor/domain/ip/hash/url (matched in real time against every newly
        analyzed sample) or query/rule/collection/researcher. Hits land in /v1/notifications and any
        configured webhook/Slack channel.
      operationId: addWatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, value]
              properties:
                kind: { type: string, enum: [family, actor, domain, ip, hash, url, query, rule, collection, researcher] }
                value: { type: string }
                label: { type: string }
      responses: { "200": { description: Watch } }
  /v1/watches/{id}:
    delete:
      summary: Delete a watch
      operationId: removeWatch
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: "{ removed: boolean }" } }
  /v1/notifications:
    get:
      summary: Alert inbox (watch hits + LiveHunt matches)
      operationId: listNotifications
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, default: 30, maximum: 200 } }
        - { name: unread, in: query, required: false, schema: { type: boolean } }
      responses: { "200": { description: "Envelope { data: Notification[], unread: number }" } }
  /v1/notifications/read:
    post:
      summary: Mark notifications read (ids[] or all=true)
      operationId: markNotificationsRead
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ids: { type: array, items: { type: string } }
                all: { type: boolean }
      responses: { "200": { description: "{ marked: number }" } }
  /v1/notifications/prefs:
    get:
      summary: Get notification delivery preferences
      operationId: getNotificationPrefs
      responses: { "200": { description: NotificationPrefs } }
    put:
      summary: Update notification delivery preferences (webhook/Slack/Teams/email/digest/mute)
      operationId: setNotificationPrefs
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                webhookUrl: { type: string, nullable: true }
                slackWebhookUrl: { type: string, nullable: true, description: Slack or Teams incoming webhook }
                emailEnabled: { type: boolean }
                digest: { type: string, enum: [off, daily, weekly] }
                muted: { type: array, items: { type: string } }
      responses: { "200": { description: NotificationPrefs } }
  /v1/hunts:
    get:
      summary: List your standing LiveHunts
      operationId: listHunts
      responses: { "200": { description: "Envelope { data: StandingHunt[] }" } }
  /v1/hunts/{id}:
    delete:
      summary: Delete a standing LiveHunt
      operationId: deleteHunt
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: "{ removed: boolean }" } }
  # ---- Community rule library / marketplace (publish + browse + fork YARA/Sigma) ----
  /v1/rules:
    get:
      summary: Browse the community rule library
      operationId: listRules
      parameters: [{ name: limit, in: query, required: false, schema: { type: integer, default: 60 } }]
      responses: { "200": { description: "Envelope { data: PublicRule[] }" } }
    post:
      summary: Publish a YARA/Sigma rule to the community library
      operationId: publishRule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, format, rule]
              properties:
                name: { type: string }
                format: { type: string, enum: [yara, sigma] }
                rule: { type: object }
                description: { type: string }
                tags: { type: array, items: { type: string } }
      responses: { "200": { description: PublicRule } }
  /v1/rules/mine:
    get: { summary: Your published rules, operationId: myRules, responses: { "200": { description: "Envelope { data: PublicRule[] }" } } }
  /v1/rules/{id}:
    get:
      summary: Get a rule by id
      operationId: getRule
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: PublicRule } }
    delete:
      summary: Delete your rule
      operationId: deleteRule
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: "{ removed: boolean }" } }
  /v1/rules/{id}/fork:
    post:
      summary: Fork a community rule into your own library
      operationId: forkRule
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: PublicRule } }
  /v1/family/{name}:
    get:
      summary: Malware family knowledge hub (samples, C2s, ATT&CK, campaigns, timeline)
      operationId: familyHub
      parameters: [{ name: name, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Family aggregate }, "404": { description: No samples for family } }
  /v1/intelligence/attack:
    get:
      summary: Corpus-wide ATT&CK technique prevalence (cross-corpus navigator heatmap)
      operationId: attackNavigator
      responses: { "200": { description: "Technique -> count" } }
  /v1/retrohunt:
    post:
      summary: Submit a retrohunt (YARA rule over the historic corpus)
      operationId: retrohunt
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, rule]
              properties:
                name: { type: string }
                rule: { type: object, description: YARA rule in the portable JSON format }
                standing: { type: boolean, default: false }
      responses:
        "200": { description: Hunt result (scanned count + matches) }
    get:
      summary: List retrohunts for the tenant
      operationId: listRetrohunts
      responses:
        "200": { description: Hunts }
  /v1/retrohunt/{huntId}:
    get:
      summary: Get a retrohunt's status + matches
      operationId: getRetrohunt
      parameters:
        - { name: huntId, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Hunt }
        "404": { description: Not found }
  /v1/reputation/batch:
    post:
      summary: Batch reputation lookup
      operationId: reputationBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                indicators: { type: array, items: { type: string } }
      responses:
        "200": { description: Reputation records }
  /v1/community/feed:
    get:
      summary: Public community feed of latest analyzed threats
      operationId: communityFeed
      responses:
        "200": { description: Feed items }
  /v1/community/trends:
    get:
      summary: Auto-generated threat-trend data (top families, emerging C2)
      operationId: trends
      responses:
        "200": { description: Threat trends }
  /v1/community/leaderboard:
    get:
      summary: Analyst leaderboard (karma, opt-in public handles)
      operationId: leaderboard
      responses:
        "200": { description: Leaderboard }
  /v1/feeds/stix:
    get:
      summary: STIX 2.1 feed of malicious indicators (subscribable)
      operationId: feedStix
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, default: 200 } }
      responses:
        "200": { description: STIX bundle }
  /v1/feeds/csv:
    get:
      summary: CSV feed of malicious indicators
      operationId: feedCsv
      responses:
        "200": { description: CSV }
  # ---- Compatibility resources: the conventional file/URL/domain/IP shape that existing
  # threat-intelligence tooling expects, so a client written against that convention works unchanged.
  # ----
  /v1/files/upload_url:
    get:
      summary: Presigned upload URL for large (>32MB) samples
      operationId: fileUploadUrl
      responses:
        "200":
          description: A presigned PUT URL and the uploadKey to reference on submit
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: string, description: presigned PUT URL }
                  uploadKey: { type: string }
        "403": { description: Requires a verified account }
        "503": { description: Upload flow not enabled on this deployment }
  /v1/files:
    post:
      summary: Submit a file (compatibility resource shape)
      operationId: filesSubmit
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubmitRequest" }
      responses:
        "202": { description: Analysis descriptor }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/files/{hash}:
    get:
      summary: Get a file report by hash
      operationId: getFile
      # Verified anonymous against production 2026-08-19. Without this the global `security`
      # requirement above applies, and every generated client and reader is told to obtain a
      # key for an endpoint that needs none. Anonymous is still METERED (1,000 lookups/day per
      # IP); `security: []` means no credential is required, not that the call is unlimited.
      security: []
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: File record }
        "404": { description: Not found }
  /v1/files/{hash}/analyse:
    post:
      summary: Re-analyze a known hash
      description: >-
        Returns the existing report by default. Pass `{ "reanalyze": true }` (authenticated) to force
        a fresh run — identical to POST /v1/files/{hash}/reanalyze.
      operationId: analyseFile
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reanalyze: { type: boolean, description: Force a fresh run (all engines + a new detonation). }
                force: { type: boolean, description: Alias of reanalyze. }
      responses:
        "200": { description: Existing report descriptor }
        "202": { description: Fresh reanalysis started (task descriptor) }
  /v1/files/{hash}/reanalyze:
    post:
      summary: Force a fresh reanalysis of a known file
      description: >-
        Re-runs the FULL pipeline (all engines + a new detonation) on the stored sample and overwrites
        the report, bypassing the (hash, analyzer-version) dedup cache. Requires an authenticated
        identity (it re-detonates = real compute) and is metered like a detonation: a private
        reanalysis spends a detonation credit and a public one counts against the free daily cap.
        Visibility is preserved (never escalated). Poll GET /v1/tasks/{taskId} or GET /v1/files/{hash}
        for the refreshed result.
      operationId: reanalyzeFile
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      responses:
        "202": { description: Reanalysis started (task descriptor) }
        "401": { description: Authentication required (reanalysis re-detonates) }
        "404": { description: Unknown hash (no stored sample) }
        "409": { description: Sample bytes not retained; a fresh run is not possible }
  /v1/files/{hash}/behaviours:
    get:
      summary: Full behavior event stream for a file
      operationId: fileBehaviours
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: Behaviours (events, process tree, ATT&CK, network) }
        "404": { description: No behaviours available }
  /v1/files/{hash}/artifacts/{kind}:
    get:
      summary: Scoped presigned download of a run artifact
      operationId: fileArtifact
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: kind, in: path, required: true, schema: { type: string, enum: [pcap, memory, screenshots, unpacked, config, replay, report] } }
      responses:
        "200":
          description: Presigned download URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: string }
                  kind: { type: string }
                  expiresInSeconds: { type: integer }
        "404": { description: No such artifact }
  /v1/files/{hash}/relationships/{type}:
    get:
      summary: Related entities (contacted IPs, dropped files, domains) via the threat graph
      operationId: fileRelationships
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: type, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Related graph nodes }
  /v1/urls:
    post:
      summary: Submit a URL (compatibility resource shape)
      operationId: urlsSubmit
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [url], properties: { url: { type: string } } }
      responses:
        "202": { description: Analysis descriptor }
  /v1/urls/{id}:
    get:
      summary: Get a URL analysis
      operationId: getUrl
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: URL analysis }
  # ---- URL scanning: static fast pass plus headless-browser deep pass ----
  /v1/scan:
    post:
      summary: Submit a URL scan (two-tier static + dynamic pipeline)
      description: >
        Scan a URL. A static fast pass (headers, TLS cert, favicon mmh3, redirect chain, resources,
        provisional verdict) runs first and streams the first signal, then an optional dynamic
        headless-browser deep pass (screenshot, DOM, network graph, cookies, file grabs) and the intel
        overlay enrich the result. Egress is SSRF-locked (private/loopback/link-local/metadata
        destinations and non-http(s) schemes are rejected, DNS is resolve-then-pinned and re-checked on
        every redirect hop). Sensitive URLs (reset/session/JWT/API-key links) are auto-scrubbed and
        force-privated. Returns a job uuid + an immediate cache hit when a fresh scan exists. Pass
        `sync=true` to await the full result. Poll GET /v1/result/{uuid} or stream its /stream SSE.
      operationId: scanSubmit
      # Verified anonymous against production 2026-08-19. Without this the global `security`
      # requirement above applies, and every generated client and reader is told to obtain a
      # key for an endpoint that needs none. Anonymous is still METERED (1,000 lookups/day per
      # IP); `security: []` means no credential is required, not that the call is unlimited.
      security: []
      parameters:
        - { name: sync, in: query, required: false, schema: { type: boolean }, description: Await the full result instead of returning a job id. }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScanRequest" }
      responses:
        "202":
          description: Scan queued (job envelope with uuid + result/stream URLs)
          content: { application/json: { schema: { $ref: "#/components/schemas/ScanJob" } } }
        "201":
          description: Full scan result (when sync=true)
          content: { application/json: { schema: { $ref: "#/components/schemas/ScanResult" } } }
        "200":
          description: Warm-cache job envelope (cached=true)
          content: { application/json: { schema: { $ref: "#/components/schemas/ScanJob" } } }
        "400": { description: Rejected by scanner safety (SSRF/scheme) }
  /v1/scan/batch:
    post:
      summary: Bulk-scan many URLs (a pasted list and/or an uploaded file of URLs/domains)
      description: >
        Submit MANY URLs at once. Provide `urls` (array) and/or `text` (freeform content, e.g. an
        uploaded file's text - URLs/domains are extracted with defang-aware parsing and benign/bogon
        filtering). Bare domains become `https://domain/`. Bulk triage defaults to the FAST (static,
        sub-second) pass so a large batch never overloads the browser deep-pass worker; an authenticated
        caller may request `tier:"standard"` (rendered). Each accepted URL becomes its own scan job,
        visible on the /firehose URL view. Per-request cap: 25 (anonymous) / 100 (authenticated).
      operationId: scanBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                urls: { type: array, items: { type: string }, description: Explicit list of URLs/domains. }
                text: { type: string, description: Freeform text (e.g. an uploaded file's content) to extract URLs/domains from. }
                tier: { type: string, enum: [fast, standard], description: "Depth; 'standard' (rendered) requires an authenticated caller. Default fast." }
                visibility: { type: string, enum: [public, private], description: "Default public; 'private' requires an authenticated caller." }
      responses:
        "202":
          description: Batch accepted (per-URL job uuids)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      submitted: { type: integer }
                      candidates: { type: integer }
                      truncated: { type: boolean }
                      tier: { type: string }
                      visibility: { type: string }
                      jobs:
                        type: array
                        items:
                          type: object
                          properties:
                            url: { type: string }
                            uuid: { type: string }
                            resultUrl: { type: string }
        "400": { description: No usable URL/domain in the request }
  /v1/result/{uuid}:
    get:
      summary: Get a URL scan result (stable schema; partial flagged while deep pass runs)
      description: Own-job status read; NOT daily-lookup metered (safe to poll).
      operationId: getScanResult
      # Verified anonymous against production 2026-08-19. Without this the global `security`
      # requirement above applies, and every generated client and reader is told to obtain a
      # key for an endpoint that needs none. Anonymous is still METERED (1,000 lookups/day per
      # IP); `security: []` means no credential is required, not that the call is unlimited.
      security: []
      parameters: [{ name: uuid, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: "URL scan result with stable top-level keys: task, page, lists, data, meta, stats, verdicts, intel, evidence"
          content: { application/json: { schema: { $ref: "#/components/schemas/ScanResult" } } }
        "404": { description: Not found or private to another tenant }
  /v1/result/{uuid}/stream:
    get:
      summary: Stream URL scan result snapshots (SSE)
      description: Server-sent events emitting result snapshots as stages complete (first signal, then deep-pass + intel enrichment). Not daily-lookup metered (own-job status).
      operationId: streamScanResult
      parameters: [{ name: uuid, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: text/event-stream of scan snapshots }
  /v1/result/{uuid}/stix:
    get:
      summary: Export a URL scan as a STIX 2.1 bundle
      description: Returns a STIX 2.1 bundle (indicator + domain-name/ipv4-addr/x509-certificate/url/file observables + observed-data) for SOAR/MISP/TAXII consumers.
      operationId: scanStix
      parameters: [{ name: uuid, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: "application/stix+json;version=2.1 bundle" }
        "404": { description: Not found or private to another tenant }
  /v1/urlscan/search:
    get:
      summary: Search historical URL scans (query language + search_after pagination)
      description: >
        Query fields: page.domain, page.ip, page.asn, page.server, page.apex, domain, ip, url, hash,
        cert, server, filename, brand, kit, actor, operator, cert.assurance, impersonates, cluster,
        verdict, category, favicon, task.tags, tag, sample, malware, family. `brand:` matches every
        brand a page merely involves; `impersonates:` is the EVIDENCED impersonation claim - use it
        for "phishing against a brand". `key:*` is an exists query (`impersonates:*` = any brand).
        An unknown filter key that is a near-miss for a real one, or that belongs to the file-search
        grammar, is REFUSED with a 400 naming the correction - never silently searched as text.
        Free text matches title/domain/urls/brands. Exact total to 10k then has_more.
      operationId: searchScans
      parameters:
        - { name: q, in: query, required: false, schema: { type: string } }
        - { name: size, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: search_after, in: query, required: false, schema: { type: string }, description: JSON array from a prior response for pagination. }
        - { name: facets, in: query, required: false, schema: { type: string, enum: ["1"] }, description: Adds verdict/impersonates/kit term counts over the current query's matches. }
      responses:
        "200": { description: "Search results: { results, total, has_more, search_after, request_time_ms }" }
  /v1/result/{uuid}/export/{format}:
    get:
      summary: Export a URL scan for a SIEM/SOAR (splunk | xsoar | cef)
      description: >
        Pull the same event our forwarders push. `splunk` returns a Splunk HEC event, `xsoar` a Cortex
        XSOAR incident, `cef` a syslog-forwardable CEF line (ArcSight/Sentinel). Use /stix for STIX 2.1.
      operationId: scanExport
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string } }
        - { name: format, in: path, required: true, schema: { type: string, enum: [splunk, xsoar, cef] } }
      responses:
        "200": { description: SIEM/SOAR export shape }
        "404": { description: Not found or private to another tenant }
  /v1/files/{hash}/download:
    get:
      summary: Download the sample bytes as a password-protected archive (password "infected")
      description: >
        Requires an authenticated principal (never anonymous) plus explicit acknowledgement via
        ?ack=1. Returns a presigned redirect to a password-protected 7z of live malware - handle
        accordingly.
      operationId: downloadSample
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: ack, in: query, required: true, schema: { type: string, enum: ["1"] } }
      responses:
        "302": { description: Presigned archive URL. }
        "401": { description: Sample download requires a signed-in account. }
  /v1/files/{hash}/report.docx:
    get:
      summary: Full branded analysis report as a Word document
      operationId: fileReportDocx
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: The document. }
        "202": { description: Large report still generating; retry after the given delay. }
  /v1/result/{uuid}/report.pdf:
    get:
      summary: Full branded URL-scan report as a PDF
      operationId: scanReportPdf
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: The document. }
        "202": { description: Still generating; retry after the given delay. }
  /v1/result/{uuid}/report.docx:
    get:
      summary: Full branded URL-scan report as a Word document
      operationId: scanReportDocx
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: The document. }
        "202": { description: Still generating; retry after the given delay. }
  /v1/brands:
    get:
      summary: Brand-attack directory (every brand with observed impersonation)
      description: >
        Brands ranked by DISTINCT impersonating domains (never raw scan rows), with flagged-domain
        counts, kits and last-seen. Public-corpus data, server-cached; unmetered. Counts describe
        this platform's scans, never global prevalence; an absent brand is untracked, not unattacked.
      operationId: brandDirectory
      responses:
        "200": { description: "Envelope { data: { brands: BrandDirectoryRow[], note } }" }
        "503": { description: URL-scan index not configured on this deployment. }
  /v1/brand/{brand}:
    get:
      summary: Impersonated-brand hub (every domain observed impersonating one brand)
      description: >
        Deduplicated by domain with first/last seen, worst verdict, kit breakdown and per-domain
        evidence (claim sources and squat kind). Split by attack shape: `lookalike` rows are domains
        that are themselves the deception (homograph/typosquat/brand token), while `hosted` rows are
        compromised or third-party sites serving brand-lure pages, where the domain owner is usually a
        victim too. Rows meet an evidence floor (flagged verdict or two independent claim sources);
        below-floor claims stay queryable via the `impersonates:` search filter. The listed domains
        impersonate the brand; none belong to it. Counts what this platform scanned.
      operationId: brandHub
      parameters:
        - { name: brand, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 200, maximum: 500 } }
      responses:
        "200": { description: "Envelope { data: { brand, label, totalScans, domains[], kits[], note } }" }
        "503": { description: URL-scan index not configured. }
  /v1/brand/{brand}/malware:
    get:
      summary: Malware linked to a brand's impersonating domains, split by evidence
      description: >
        `attacks` carries runtime-observed or config-derived links (hosting / extracted C2 / contact).
        `references` carries samples that merely CONTAIN one of the hostnames as a string - NOT
        evidence of contact, never counted as an attack. The split is structural, not cosmetic.
      operationId: brandMalware
      parameters:
        - { name: brand, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100, maximum: 200 } }
      responses:
        "200": { description: "Envelope { data: { brand, label, domains, attacks[], references[], truncated, note } }" }
        "503": { description: URL-scan index or correlation store not configured. }
  /v1/brand/{brand}/export/{fmt}:
    get:
      summary: Export a brand's impersonating domains (csv/json free; stix/misp premium)
      description: >
        CSV and JSON are free at every tier. STIX 2.1 and MISP consume the daily brand allowance
        (X-Brand-Allowance-* headers), then credits - 429 with signinRequired for anonymous callers
        past the allowance, 402 insufficient_credits on an empty wallet. STIX indicators are the
        impersonating DOMAINS; the brand itself is the victim, never the indicator.
      operationId: brandExport
      parameters:
        - { name: brand, in: path, required: true, schema: { type: string } }
        - { name: fmt, in: path, required: true, schema: { type: string, enum: [csv, json, stix, misp] } }
      responses:
        "200": { description: The export document (text/csv or application/json). }
        "402": { description: Empty wallet past the free allowance (insufficient_credits). }
        "404": { description: No impersonation observed for this brand. }
        "429": { description: Anonymous past the daily allowance (signinRequired). }
  /v1/urlscan/similar:
    post:
      summary: Unified multi-space similarity for URL scans
      description: Ranks matches across favicon mmh3, DOM hash/structure, and infra (apex/IP/cert) spaces with per-space scores + a fused rank. Query by scan uuid or a faviconMmh3/domSha256.
      operationId: similarScans
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                uuid: { type: string }
                faviconMmh3: { type: integer }
                domSha256: { type: string }
                size: { type: integer, default: 20 }
      responses:
        "200": { description: "Ranked similar scans with per-space scores" }
  /v1/urlscan/retrohunt:
    post:
      summary: Retrohunt — re-score URL-scan history against a new signature/IOC set
      operationId: retrohuntScans
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string }
                limit: { type: integer, default: 50, maximum: 100 }
      responses:
        "200": { description: "Backward matches over the scan index" }
  /v1/feed/newly-observed:
    get:
      summary: Live feed of newly-observed hostnames/domains with verdict enrichment
      operationId: newlyObservedFeed
      parameters: [{ name: limit, in: query, required: false, schema: { type: integer, default: 100, maximum: 200 } }]
      responses:
        "200": { description: Newly-observed feed }
  /v1/feed/malicious:
    get:
      summary: Feed of detected malicious URLs (filter by brand/kit/actor)
      operationId: maliciousFeed
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100, maximum: 200 } }
        - { name: brand, in: query, required: false, schema: { type: string } }
        - { name: kit, in: query, required: false, schema: { type: string } }
        - { name: actor, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: Malicious-URL feed }
  /v1/urlscan/recent:
    get:
      summary: Newest public URL scans (unmetered; powers the /scan history + community list)
      description: >
        Completed, public URL scans newest-first. Unmetered (does not consume the daily lookup quota),
        public-only (never private/unlisted), each row carrying the scan uuid for linking.
      operationId: urlscanRecent
      parameters: [{ name: limit, in: query, required: false, schema: { type: integer, default: 30, maximum: 60 } }]
      responses:
        "200":
          description: Recent public scans
          content:
            application/json:
              schema:
                type: object
                properties:
                  scans:
                    type: array
                    items:
                      type: object
                      properties:
                        uuid: { type: string }
                        url: { type: string }
                        hostname: { type: string, nullable: true }
                        verdict: { type: string, enum: [malicious, suspicious, benign, unknown] }
                        score: { type: integer }
                        malicious: { type: boolean }
                        time: { type: string, format: date-time }
                        title: { type: string, nullable: true }
  /v1/domains/{domain}:
    get:
      summary: Domain reputation + relationships
      operationId: getDomain
      security: []
      parameters: [{ name: domain, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: Domain record }
  /v1/ip_addresses/{ip}:
    get:
      summary: IP address reputation + relationships
      operationId: getIpAddress
      security: []
      parameters: [{ name: ip, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: IP record }
  # ---- Unified analysis: one endpoint that accepts either a file or a URL ----
  /v1/analysis:
    post:
      summary: Submit a unified analysis (file or URL)
      operationId: analysisSubmit
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubmitRequest" }
      responses:
        "201": { description: Analysis created with a uuid }
  /v1/analysis/{uuid}:
    get:
      summary: Get a unified analysis by uuid
      operationId: getAnalysis
      parameters: [{ name: uuid, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: Analysis + report }
        "404": { description: Not found }
  # ---- Intelligence superset ----
  /v1/intelligence/search:
    get:
      summary: Advanced intelligence search (grammar v2)
      description: >-
        VT-style advanced search. Entity selector `entity:file|domain|ip|url` (default file).
        Structured filters: `p:5+`/`p:5-` (positive detections), `size:500KB+` (KB/MB/GB),
        `fs:2024-01-01+`/`ls:2025-06-30-` (first/last seen), `submissions:3+` (prevalence).
        String filters: `engines:` (AV label contains), `name:`, `type:`, `family:`, `verdict:`,
        `tag:`, `md5:`, `sha1:`, `sha256:`, `imphash:`, `ssdeep:`, `tlsh:`, `domain:`, `ip:`,
        `url:`, `host:`, `path:`, `tld:`, `cidr:`, `ioc:`, `strings:` (alias `content:`; phrase match
        over the sample's extracted printable strings, e.g. `strings:"cmd.exe /c"`). Values with spaces may be quoted
        (`name:"a b.exe"`). Bare tokens match hash / filename / family / IOC substrings.
        Domain/IP/URL entity hits aggregate per-sample IOC observations joined with reputation.
        Paginated: set `limit` for the page size and echo `meta.search_after` from the previous
        response back as `search_after` to page a large result set (e.g. every malicious sample in a
        date range). `meta.total` is the full match count and `meta.has_more` says whether more remain.
      operationId: intelligenceSearch
      parameters:
        - { name: query, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 100 }, description: Page size (cap 100). }
        - name: search_after
          in: query
          required: false
          description: >-
            Opaque pagination cursor: pass the JSON-encoded `meta.search_after` array returned by the
            previous response to fetch the next page. Malformed cursors return 400.
          schema: { type: string }
      responses:
        "200":
          description: >-
            Matching entities (type file|domain|ip|url). Envelope `{ data, meta }`; `meta` carries
            `count`, `total`, `has_more`, and `search_after` (the cursor for the next page, or null).
        "400": { description: Invalid search_after cursor }
  /v1/intelligence/multisearch:
    post:
      summary: Bulk-resolve every indicator found in pasted text (VT multisearch)
      description: >-
        Extracts every recognizable sha256/md5/sha1 hash, domain, IP and URL from arbitrary text
        (newline/CSV/XML, defanged forms like hxxp://evil[.]com are refanged) and resolves each
        against the platform: hashes to reports (md5/sha1 resolve to their sha256), domains/IPs/
        URLs to reputation + graph presence. Up to 1000 indicators per call; each distinct
        indicator consumes one daily-lookup unit.
      operationId: intelligenceMultisearch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                input: { type: string, description: Free-form text to extract indicators from }
                indicators: { type: array, items: { type: string }, description: Pre-split indicator list (alternative to input) }
      responses:
        "200": { description: One row per indicator with found flag + attributes }
        "400": { description: Missing input }
  /v1/intelligence/livehunt:
    post:
      summary: Register a standing YARA Livehunt with match alerting
      operationId: livehunt
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [name, rule], properties: { name: { type: string }, rule: { type: object } } }
      responses:
        "201": { description: Livehunt created }
  /v1/intelligence/grep:
    get:
      summary: Content/byte search across the corpus (string, hex, or regex)
      operationId: intelligenceGrep
      parameters: [{ name: pattern, in: query, required: true, schema: { type: string } }, { name: kind, in: query, schema: { type: string, enum: [string, hex, regex] } }]
      responses: { "200": { description: Matching samples } }
  /v1/files/{hash}/similar:
    get:
      summary: Similar samples via imphash/vhash + fuzzy (ssdeep/tlsh/ctph) hashing
      operationId: similarSamples
      parameters: [{ name: hash, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: Ranked similar samples } }
  /v1/collections:
    get: { summary: List your investigations, operationId: listCollections, responses: { "200": { description: Collections } } }
    post: { summary: Create an investigation (samples + IOCs + notes), operationId: createCollection, responses: { "201": { description: Created } } }
  /v1/usage:
    get: { summary: Your daily lookup quota, rewards, and submission counts, operationId: usage, responses: { "200": { description: Usage } } }
  /v1/me/submissions:
    get: { summary: Your submissions with verdicts + report links, operationId: mySubmissions, responses: { "200": { description: Submissions } } }
  /v1/dl/manifest:
    get:
      summary: Desktop client / extension / add-in download manifest
      description: >
        Public, unmetered list of the downloadable desktop clients, browser extension and Outlook
        add-in, with per-artifact platform, SHA-256, size and a `/v1/dl/{name}` download path. Emitted
        by the build so it can never advertise an artifact that does not exist; also read by the client
        version-check.
      operationId: downloadsManifest
      responses:
        "200": { description: Manifest }
        "503": { description: Downloads not configured on this deployment }
  /v1/dl/{name}:
    get:
      summary: Download one distribution artifact
      description: >
        302-redirects to a short-lived presigned URL for the named artifact. The name is validated
        against the manifest, so this is not an open proxy to storage.
      operationId: downloadArtifact
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        "302": { description: Redirect to the artifact }
        "404": { description: No such artifact }
        "503": { description: Downloads not configured }
  /v1/status:
    get: { summary: Public real-time platform status, operationId: platformStatus, responses: { "200": { description: Status } } }
  /v1/status/history:
    get:
      summary: Windowed activity history for the Status page (file samples + URL scans)
      description: >
        Aggregate-only activity over a selectable window. File-sample activity is in `hours` +
        `verdicts24h` (+ `sources24h`/`families24h`); PUBLIC URL-scan activity is a separate additive
        series in `urlHours` + `urlVerdicts24h` (never mixed into the file-sample tiles).
      operationId: statusHistory
      parameters:
        - { name: window, in: query, required: false, schema: { type: string, enum: ["24h", "7d", "30d", "180d", "1y"], default: "24h" } }
      responses:
        "200":
          description: Activity history
          content:
            application/json:
              schema:
                type: object
                properties:
                  window: { type: string }
                  granularity: { type: string, enum: [hour, day, week] }
                  coverageStart: { type: string, nullable: true }
                  hours: { type: array, items: { type: object } }
                  verdicts24h: { type: object }
                  sources24h: { type: array, items: { type: object } }
                  families24h: { type: array, items: { type: object } }
                  urlHours:
                    type: array
                    description: PUBLIC URL-scan activity per bucket (separate population from file samples).
                    items:
                      type: object
                      properties:
                        t: { type: string, format: date-time }
                        scanned: { type: integer }
                        malicious: { type: integer }
                        suspicious: { type: integer }
                        clean: { type: integer }
                        other: { type: integer }
                  urlVerdicts24h:
                    type: object
                    properties:
                      scanned: { type: integer }
                      malicious: { type: integer }
                      suspicious: { type: integer }
                      clean: { type: integer }
                      other: { type: integer }
  /v1/wallet:
    get: { summary: Your credit balance (free + purchased + used + remaining). Credits meter the Agent + premium compute., operationId: wallet, responses: { "200": { description: Wallet } } }
  /v1/agent/query:
    post:
      summary: Ask MalwareAnalyser Agent (registered users). Consumes credits (by LLM usage).
      operationId: agentQuery
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [query], properties: { query: { type: string }, mode: { type: string, enum: [quick, advanced] } } }
      responses:
        "200": { description: Answer + citations + credit usage }
        "402": { description: Out of credits (top up or upgrade) }
  /v1/billing/token-packs:
    get: { summary: The three credit packs ($50->13,000, $100->28,000, $200->60,000 credits), operationId: tokenPacks, responses: { "200": { description: Packs } } }
  /v1/billing/tokens/checkout:
    post:
      summary: Start a Stripe Checkout session for a credit pack
      operationId: tokenCheckout
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [pack], properties: { pack: { type: string, enum: ["50", "100", "200"] } } }
      responses: { "200": { description: Checkout URL } }
  /v1/mcp:
    post:
      summary: MalwareAnalyser MCP endpoint (JSON-RPC 2.0). Connect from Claude/Cursor with a tl_pat_* bearer.
      operationId: mcp
      security: [{ BearerAuth: [] }]
      responses: { "200": { description: JSON-RPC result } }
  /v1/files/{hash}/history:
    get:
      summary: Per-hash submission-history / sightings ledger (first-seen, first-analyzed, re-analyses)
      description: >
        Visibility-scoped, submitter-redacted. Metered `none`. Resolves md5/sha1/sha256.
      operationId: fileHistory
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 50 } }
      responses:
        "200": { description: Sightings summary + recent submissions }
        "404": { description: No history for that hash }
  /v1/files/{hash}/urls:
    get:
      summary: Reverse correlation - the URLs a malware sample communicates to
      description: >
        The URLs/domains this sample references or contacts (dynamic network destinations, extracted
        C2/config, static IOCs), each with its edge kind (c2/config/download/contacted/referenced/dns),
        a `hosting` flag (the URL serves this sample's payload/config), and the URL-scan uuid when the
        URL was scanned - so a report links directly into the live URL analysis. Each row also carries
        that scan's own `verdict`/`score`/`malicious`, so a consumer can distinguish a URL flagged on
        its own evidence from one the sample merely references. A `null` verdict means the URL has NOT
        been scanned (or the scan has not finished); it does not mean clean - see `scanStatus`.
        `scanUuid`/`scanUrl` are only returned once the scan is confirmed to resolve for THIS caller,
        so the link never leads to a 404. Visibility-scoped; metered `none`. Resolves md5/sha1/sha256.
      operationId: fileUrls
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100 } }
      responses:
        "200":
          description: Correlated URLs
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        url: { type: string }
                        domain: { type: string }
                        edgeKind: { type: string, enum: [c2, download, config, contacted, referenced, dns] }
                        hosting: { type: boolean }
                        scanUuid: { type: string, nullable: true, description: Null unless a scan exists AND resolves for this caller. }
                        scanUrl: { type: string, nullable: true }
                        scanStatus:
                          type: string
                          nullable: true
                          enum: [queued, running, done, error]
                          description: State of the linked scan; `null` when the URL has no scan at all.
                        verdict:
                          type: string
                          nullable: true
                          enum: [malicious, suspicious, benign, unknown]
                          description: Verdict of this URL's own scan. `null` = not scanned or unfinished (not "clean").
                        score: { type: integer, nullable: true, description: "URL-scan score (-100..100); null when unscanned." }
                        malicious: { type: boolean, description: The URL scanner's own malicious flag; false when unscanned. }
                        firstSeen: { type: string, format: date-time }
                        lastSeen: { type: string, format: date-time }
        "404": { description: File not found }
  /v1/files/{sha256}/share:
    post:
      summary: Flip a report's visibility (owner) and return a shareable URL when made public
      operationId: shareReport
      parameters: [{ name: sha256, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, properties: { visibility: { type: string, enum: [public, private] } } }
      responses:
        "200": { description: New visibility + shareUrl }
        "404": { description: Unknown hash }
components:
  securitySchemes:
    ApiKeyAuth: { type: apiKey, in: header, name: X-Api-Key }
    BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
  responses:
    RateLimited:
      description: Too many requests
      headers:
        Retry-After: { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
  schemas:
    SubmitRequest:
      type: object
      properties:
        fileBase64: { type: string, description: base64-encoded sample bytes }
        url: { type: string }
        hash: { type: string }
        uploadKey: { type: string, description: key from /v1/files/upload_url for large samples }
        fileName: { type: string, nullable: true }
        visibility: { type: string, enum: [public, private], default: private }
        interactive: { type: boolean, default: false }
        network: { type: string, enum: [full, simulated, none], default: simulated }
        os: { type: string, enum: [windows, linux, android], description: "requested guest OS. Windows + Linux run full-system sandboxes; Android runs the droidy tier. Mach-O/macOS has no full-system sandbox (lightweight emulation only)." }
        arch: { type: string, enum: [x86_64, aarch64] }
        engine:
          type: string
          enum: [cape, capelinux, qemu, drakvuf, droidy, emulator, pyrebox, detux, multi]
          description: "preferred detonation engine (multi-engine router honors when supported). Note: cuckoo is a dormant, unwired adapter and is not selectable in production."
        deep: { type: boolean, default: false, description: opt-in record/replay + taint + coverage }
    ScanRequest:
      type: object
      required: [url]
      properties:
        url: { type: string, description: URL to scan (http/https only) }
        tier:
          type: string
          enum: [fast, standard, deep]
          default: standard
          description: "fast=static-only sub-second; standard=full render; deep=multi-vantage + residential + Tor + file detonation + full ML (authenticated tenants only)"
        visibility:
          type: string
          enum: [public, unlisted, private]
          default: private
          description: Private-by-default. Opt-in public only survives if the sensitive-URL scrubber passes it.
        vantages:
          type: array
          items: { type: string }
          description: Vantage points to fan out across (geo pops / residential / datacenter / tor).
        useResidential: { type: boolean, default: false, description: Residential-IP egress (authenticated tenants; rate-limited). }
        tor: { type: boolean, default: false, description: Tor egress for .onion / evasion (authenticated tenants; rate-limited). }
        grabFiles: { type: boolean, default: false, description: Route downloaded files to the malware sandbox for real detonation. }
        interaction: { type: string, enum: [none, basic, aggressive], default: basic, description: Interaction driver to surface interaction-gated payloads. }
        rescan: { type: boolean, default: false, description: Bypass the warm cache and force a fresh scan. }
        tags: { type: array, items: { type: string }, description: Freeform tags attached to the scan (searchable). }
        timeout: { type: integer, description: Per-scan timeout in ms (capped at 30000). }
        captchaToken: { type: string, description: CAPTCHA token for anonymous submissions. }
    ScanJob:
      type: object
      description: Job envelope returned by POST /v1/scan (async) or the warm-cache hit.
      properties:
        data:
          type: object
          properties:
            uuid: { type: string }
            status: { type: string, enum: [queued, running, partial, done, error] }
            cached: { type: boolean }
            verdict: { type: string }
            score: { type: integer }
            resultUrl: { type: string }
            streamUrl: { type: string }
    ScanResult:
      type: object
      description: Full URL scan result (stable top-level keys). Missing fields never break consumers.
      properties:
        data:
          type: object
          properties:
            schemaVersion: { type: integer }
            uuid: { type: string }
            status: { type: string, enum: [queued, running, partial, done, error] }
            partial: { type: boolean }
            task: { type: object }
            page: { type: object }
            lists: { type: object }
            data: { type: object }
            meta: { type: object }
            stats: { type: object }
            verdicts:
              type: object
              properties:
                score: { type: integer, minimum: -100, maximum: 100 }
                verdict: { type: string, enum: [malicious, suspicious, benign, unknown] }
                confidence: { type: number }
                malicious: { type: boolean }
                brands: { type: array, items: { type: string } }
                categories: { type: array, items: { type: string } }
                subscores:
                  type: object
                  properties:
                    ourEngine: { type: integer }
                    thirdParty: { type: integer }
                    community: { type: integer }
                signals:
                  type: array
                  items:
                    type: object
                    properties:
                      id: { type: string }
                      label: { type: string }
                      category: { type: string }
                      weight: { type: integer }
                      detail: { type: string }
            intel: { type: object }
            evidence: { type: object }
    Verdict:
      type: object
      properties:
        verdict: { type: string, enum: [clean, suspicious, malicious, unknown] }
        score: { type: integer, minimum: 0, maximum: 100 }
        family: { type: string, nullable: true }
        attackTechniques: { type: array, items: { type: string } }
    SubmitResponse:
      type: object
      properties:
        taskId: { type: string }
        state: { type: string }
        contentHash: { type: string }
        reportUrl: { type: string }
        pollUrl: { type: string }
        streamUrl: { type: string, description: WebSocket endpoint }
        queuePosition: { type: integer, minimum: 1 }
        etaSeconds: { type: integer, minimum: 0 }
    Task:
      type: object
      properties:
        taskId: { type: string }
        state:
          type: string
          enum: [queued, static, queued-dynamic, provisioning, running, analyzing, enriching, scored, indexed, completed, failed, timed-out, cancelled]
        contentHash: { type: string }
        queuePosition: { type: integer }
        etaSeconds: { type: integer }
        progress:
          type: object
          properties:
            stage: { type: string }
            pct: { type: integer, minimum: 0, maximum: 100 }
        staticVerdict: { $ref: "#/components/schemas/Verdict" }
        reportReady: { type: boolean }
        provenance: { $ref: "#/components/schemas/SourceProvenance" }
    Report:
      type: object
      properties:
        contentHash: { type: string }
        fileType: { type: string }
        verdict: { $ref: "#/components/schemas/Verdict" }
        hashes: { type: object, additionalProperties: { type: string } }
        yara: { type: array, items: { type: object } }
        iocs: { type: object }
        provenance: { $ref: "#/components/schemas/SourceProvenance" }
        dynamic:
          type: object
          nullable: true
          description: >
            Free-form dynamic-analysis section. Its network.destinations[] entries each carry an
            optional `ipIntel` object (see the IpIntel schema) with country/ASN plus AbuseIPDB
            community-reputation fields.
        dynamicDegraded: { type: boolean }
        dynamicDegradedReason: { type: string }
        attack:
          type: array
          items: { type: string }
          description: >
            EVERY MITRE ATT&CK technique id attributed to this sample, deduped and sorted. Read this
            rather than assembling it yourself: the underlying techniques are recorded in three
            places (the fused static verdict, the runtime capture, and individual verdict signals),
            and any one of them can legitimately be empty while the others are not. The component
            fields remain available if you need to know which source a technique came from.
          example: ['T1055', 'T1105', 'T1566.002']
        behavior:
          type: object
          description: >
            Whether behavioural analysis ran, and if not, why not. Always present, so a client can
            branch on it instead of inferring from the absence of `dynamic`. `analyzed: false` is a
            statement of fact, not an error - an archive, for example, is unpacked and its members
            are analysed as their own samples while the container itself is never executed.
          properties:
            analyzed:
              type: boolean
              description: True when a behavioural capture exists on this report.
            reason:
              type: string
              description: >
                Why there is no capture. `archive_container` (members analysed separately),
                `not_detonated` (never sent to a sandbox), or the specific degradation reason when
                detonation was attempted and did not produce a usable capture.
              example: archive_container
            detail:
              type: string
              description: A sentence explaining the reason, suitable for display to an end user.
    IpIntel:
      type: object
      description: >
        Registration/geolocation + reputation for a destination IP, attached asynchronously by the
        ip-intel worker onto dynamic.network.destinations[].ipIntel. Display-only context; never a
        verdict driver.
      properties:
        countryCode: { type: string }
        countryName: { type: string }
        city: { type: string }
        asn: { type: string, description: 'e.g. "AS15169"' }
        asOrg: { type: string }
        isp: { type: string }
        openPorts: { type: array, items: { type: integer }, description: ports an external scanner saw LISTENING (not ports the sample contacted) }
        abuseConfidenceScore: { type: integer, description: "AbuseIPDB confidence 0-100" }
        abuseTotalReports: { type: integer }
        abuseUsageType: { type: string }
        abuseLastReportedAt: { type: string, format: date-time }
        isTor: { type: boolean }
        source: { type: string, enum: [shodan, rdap, cymru, mixed, abuseipdb] }
        fetchedAt: { type: string, format: date-time }
    SourceProvenance:
      type: object
      required: [source, externalId, sourceUrl, firstSeenAt, fetchedAt, tags, hashVerified, terms]
      properties:
        source: { type: string, enum: [malwarebazaar, malwarebazaar-datalake, urlhaus, thezoo, virussign, malshare, user, upload] }
        externalId: { type: string }
        sourceUrl: { type: string, format: uri }
        originalUrl: { type: string, format: uri }
        firstSeenAt: { type: string, format: date-time }
        fetchedAt: { type: string, format: date-time }
        tags: { type: array, items: { type: string } }
        malwareFamily: { type: string }
        expectedSha256: { type: string, pattern: "^[a-fA-F0-9]{64}$" }
        hashVerified: { type: boolean }
        terms: { type: string, enum: [abuse.ch, virussign, malshare] }
    Reputation:
      type: object
      properties:
        known: { type: boolean }
        indicator: { type: string }
        verdict: { type: string }
        score: { type: integer }
        prevalence: { type: integer }
        firstSeen: { type: string }
        lastSeen: { type: string }
    GraphPivot:
      type: object
      properties:
        center: { type: object }
        nodes: { type: array, items: { type: object } }
        edges: { type: array, items: { type: object } }
