openapi: 3.0.3
info:
  title: VulnScope API
  version: 2.0.0
  license:
    name: Proprietary
  description: |
    Bounded, unauthenticated reconnaissance for authorised public websites.
    No authentication is currently required. VulnScope observes public responses
    and does not exploit vulnerabilities, submit forms, bypass authentication, or
    certify a target as secure. Only scan systems you own or are authorised to test.
servers:
  - url: https://vulnscope.illek.ie
security: []
x-ai-usage:
  preferred-interface: REST or JSON MCP
  mcp-endpoint: https://vulnscope.illek.ie/mcp/v2
  mcp-protocol-version: 2025-11-25
  mcp-protocol-versions-supported: [2025-11-25, 2025-06-18]
  mcp-tools: [scan_website, get_vulnscope_report]
  instructions:
    - Confirm the user is authorised to assess the target before calling the tool.
    - Supply only a public HTTP or HTTPS URL; private, local, and unsafe redirect targets are blocked.
    - Leave probePaths false unless sensitive-path evidence is specifically needed.
    - Leave checkTakeover false unless takeover evidence is specifically needed.
    - Leave checkWordPress false unless WordPress deep checks are specifically needed.
    - Leave probeTrace false unless a TRACE/XST probe is specifically needed.
    - A partial or INCOMPLETE report must be described with its coverage gaps.
    - Findings are reconnaissance guidance, not proof of exploitability or security.
x-availability: Open for testing. Limited requests.
paths:
  /api/v2/scan:
    post:
      operationId: scanWebsite
      summary: Scan an authorised public website
      description: Performs bounded checks for headers, cookies, CORS, DNS, public exposure, fingerprints, secrets, advertised HTTP methods, and optional WordPress, TRACE, path, and takeover evidence.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScanRequest' }
            example: { url: 'https://example.com', probePaths: false, checkTakeover: false, checkWordPress: false, probeTrace: false }
      responses:
        '201':
          description: Completed or partial scan report
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScanReport' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { description: 'Target is disallowed, non-public, or failed SSRF validation' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /api/scans/stream:
    post:
      operationId: streamWebsiteScan
      summary: Scan with newline-delimited progress events
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/ScanRequest' } } }
      responses:
        '200':
          description: NDJSON progress events ending with a result or error event. The stream is synchronous and is not an SSE connection.
          content:
            application/x-ndjson:
              schema:
                type: string
                description: One JSON object per line with type progress, result, or error.
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { description: 'Target is disallowed, non-public, or failed SSRF validation' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /api/scans/{reportId}:
    get:
      operationId: getVulnScopeReport
      summary: Retrieve an unexpired scan report
      description: Stored reports are immutable, so the response carries a strong ETag; send it as If-None-Match to revalidate without re-downloading.
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}$' } }
        - { name: If-None-Match, in: header, required: false, schema: { type: string }, description: 'A previously received ETag, a comma-separated list of validators, or *.' }
      responses:
        '200':
          description: Stored scan report
          headers:
            ETag: { schema: { type: string }, description: Strong validator over the returned representation. }
          content: { application/json: { schema: { $ref: '#/components/schemas/ScanReport' } } }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { description: Report not found or expired }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
  /api/scans/{reportId}/export:
    get:
      operationId: exportVulnScopeReport
      summary: Download an unexpired scan report as formatted JSON or Markdown
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}$' } }
        - name: format
          in: query
          required: false
          schema: { type: string, enum: [json, markdown], default: json }
          description: 'json returns a formatted .json attachment; markdown returns a human-readable .md document suitable for tickets and review docs.'
      responses:
        '200':
          description: Report attachment in the requested format
          headers:
            ETag: { schema: { type: string }, description: Strong validator over the returned representation; send as If-None-Match to revalidate. }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScanReport' }
            text/markdown:
              schema: { type: string }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: Report not found or expired }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
components:
  schemas:
    ScanRequest:
      type: object
      additionalProperties: false
      required: [url]
      properties:
        url: { type: string, format: uri, maxLength: 2048, description: Public HTTP or HTTPS URL the caller is authorised to assess. }
        probePaths: { type: boolean, default: false, description: Opt in to the bounded catalogue of public sensitive paths. }
        checkTakeover: { type: boolean, default: false, description: Run bounded subdomain takeover evidence checks. }
        checkWordPress: { type: boolean, default: false, description: Opt in to bounded WordPress deep checks. Ignored when the target is not identified as WordPress. }
        probeTrace: { type: boolean, default: false, description: Opt in to a non-mutating TRACE probe. Default scans only read the OPTIONS Allow header. }
    ScanReport:
      type: object
      required: [schemaVersion, id, requestedUrl, hostname, status, createdAt, expiresAt, totalDurationMs, observation, outbound, coverage, dns, ssl, fingerprint, headers, cookies, exposedPaths, cors, findings, summary]
      properties:
        schemaVersion: { type: integer, enum: [2] }
        id: { type: string, description: Report identifier. }
        requestedUrl: { type: string, format: uri }
        hostname: { type: string }
        status: { type: string, enum: [complete, partial, failed] }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        totalDurationMs: { type: integer, minimum: 0 }
        observation: { type: object, additionalProperties: true, description: Cloudflare edge vantage and measurement disclaimer. }
        outbound: { $ref: '#/components/schemas/OutboundSummary' }
        coverage: { $ref: '#/components/schemas/Coverage' }
        dns: { type: object, additionalProperties: true }
        ssl: { type: object, additionalProperties: true }
        fingerprint: { type: object, additionalProperties: true }
        headers: { type: object, additionalProperties: true }
        cookies: { type: array, items: { type: object, additionalProperties: true } }
        exposedPaths: { type: array, items: { type: object, additionalProperties: true } }
        cors: { type: object, additionalProperties: true }
        takeover: { type: array, items: { type: object, additionalProperties: true } }
        secrets: { type: array, items: { type: object, additionalProperties: true } }
        wordpress: { type: array, items: { type: object, additionalProperties: true } }
        methods: { type: object, additionalProperties: true }
        findings:
          type: array
          items: { $ref: '#/components/schemas/Finding' }
        summary: { $ref: '#/components/schemas/Summary' }
    Coverage:
      type: object
      additionalProperties: false
      required: [mainFetch, headers, tlsProtocolCipher, certificateEvidence, dns, cookies, paths, cors, secrets, wordpress, methods, takeover, criticalGaps]
      properties:
        mainFetch: { $ref: '#/components/schemas/CoveragePhase' }
        headers: { $ref: '#/components/schemas/CoveragePhase' }
        tlsProtocolCipher: { $ref: '#/components/schemas/CoveragePhase' }
        certificateEvidence: { $ref: '#/components/schemas/CoveragePhase' }
        dns: { $ref: '#/components/schemas/CoveragePhase' }
        cookies: { $ref: '#/components/schemas/CoveragePhase' }
        paths: { $ref: '#/components/schemas/CoveragePhase' }
        cors: { $ref: '#/components/schemas/CoveragePhase' }
        secrets: { $ref: '#/components/schemas/CoveragePhase' }
        wordpress: { $ref: '#/components/schemas/CoveragePhase' }
        methods: { $ref: '#/components/schemas/CoveragePhase' }
        takeover: { $ref: '#/components/schemas/CoveragePhase' }
        criticalGaps: { type: array, items: { type: string } }
    CoveragePhase:
      type: object
      additionalProperties: false
      required: [status, detail]
      properties:
        status: { type: string, enum: [measured, unavailable, skipped, failed, partial] }
        detail: { type: string }
        requested: { type: boolean }
        attempts: { type: integer, minimum: 0 }
        succeeded: { type: integer, minimum: 0 }
        failed: { type: integer, minimum: 0 }
        skipped: { type: integer, minimum: 0 }
        bytes: { type: integer, minimum: 0 }
        truncated: { type: boolean }
        errors: { type: array, items: { type: string } }
    OutboundSummary:
      type: object
      additionalProperties: false
      required: [maxSubrequests, maxConcurrent, maxDurationMs, requestsAttempted, requestsSucceeded, requestsFailed, requestsSkipped, activePeak, bodyBytes, truncatedBodies, redirects]
      properties:
        maxSubrequests: { type: integer, minimum: 1 }
        maxConcurrent: { type: integer, minimum: 1 }
        maxDurationMs: { type: integer, minimum: 1 }
        requestsAttempted: { type: integer, minimum: 0 }
        requestsSucceeded: { type: integer, minimum: 0 }
        requestsFailed: { type: integer, minimum: 0 }
        requestsSkipped: { type: integer, minimum: 0 }
        activePeak: { type: integer, minimum: 0 }
        bodyBytes: { type: integer, minimum: 0 }
        truncatedBodies: { type: integer, minimum: 0 }
        redirects: { type: array, items: { type: object, additionalProperties: false, required: [from, to, status], properties: { from: { type: string }, to: { type: string }, status: { type: integer } } } }
    Finding:
      type: object
      required: [id, severity, category, title, detail, evidence, recommendation]
      properties:
        id: { type: string }
        severity: { type: string, enum: [critical, high, medium, low, info] }
        category: { type: string, enum: [exposed-path, missing-header, weak-tls, cors, cookie, takeover, fingerprint, information-disclosure, secret, wordpress, method] }
        title: { type: string }
        detail: { type: string }
        evidence: { type: string }
        recommendation: { type: string }
    Summary:
      type: object
      required: [grade, critical, high, medium, low, info]
      properties:
        grade: { type: string, enum: [A, B, C, D, F, INCOMPLETE] }
        critical: { type: integer, minimum: 0 }
        high: { type: integer, minimum: 0 }
        medium: { type: integer, minimum: 0 }
        low: { type: integer, minimum: 0 }
        info: { type: integer, minimum: 0 }
    Error:
      type: object
      additionalProperties: true
      properties:
        error: { type: string }
    RateLimitError:
      type: object
      required: [error, limit, remaining, resetAt]
      properties:
        error: { type: string }
        limit: { type: integer, minimum: 1, description: Daily allowance for this caller class. }
        remaining: { type: integer, minimum: 0, description: Requests left in the current daily window. }
        resetAt: { type: string, format: date-time, description: End of the current daily window (UTC). }
  responses:
    BadRequest:
      description: Invalid JSON, URL, or options
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotModified:
      description: The stored representation still matches the supplied If-None-Match validator; the body is empty.
      headers:
        ETag: { schema: { type: string }, description: The validator that satisfied the conditional request. }
    MethodNotAllowed:
      description: The resource exists but does not answer this method; the Allow header names the supported set.
      headers:
        Allow: { schema: { type: string }, description: Methods this resource answers. }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServiceUnavailable:
      description: Both public DNS resolvers were unavailable, so the target could not be checked. The request is refunded and may be retried.
      headers:
        Retry-After: { schema: { type: integer }, description: Suggested seconds to wait before retrying. }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Request limit reached
      headers:
        RateLimit-Limit: { schema: { type: integer }, description: Daily allowance for this caller class. }
        RateLimit-Remaining: { schema: { type: integer }, description: Always 0 once the limit is reached. }
        RateLimit-Reset: { schema: { type: integer }, description: Seconds until the daily window resets. }
        Retry-After: { schema: { type: integer }, description: Seconds to wait before retrying. }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/RateLimitError' }
