diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e6fa762..7815e0c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,47 @@ jobs: working-directory: api run: GOWORK=off go test ./tools/lint/getbyid/... -count=1 + # ============================================ + # OpenAPI contract gate — api/openapi/swagger.yaml is generated by swag from + # the handler // @Router annotations, and until now nothing checked that the + # committed file still matched them. It had drifted to the point of describing + # a different server: 30 documented paths with no route anywhere in the repo + # (a closed-source era leftover), and 40 real endpoints missing, including the + # entire /notifications API. + # + # That is a client bug, not a docs bug — the UI generates its API types from + # this file, so a stale entry ships a request to an endpoint that does not + # exist and a missing entry hides a feature from every client. + # + # This compares SETS of operations (annotations vs spec vs registered routes), + # not the bytes of the generated file. A byte gate was tried first and failed + # here for a reason worth recording: swag is not hermetic. A clean runner + # drops `format: int64` from some map[string]int64 fields that a developer + # machine emits — same pinned swag, same Go, warm module cache. Both documents + # describe the same API, so the gate would have failed on a non-disagreement + # nobody could fix, and would have been deleted. See + # tools/lint/openapicontract. + # + # Deliberately UNCONDITIONAL: no `if:` on event_name, no base-ref lookup, and + # the script fails (rather than skips) when its inputs are missing. + # Conditional gates in this repository have twice turned out never to run. + # ============================================ + openapi: + name: OpenAPI Contract + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Go + uses: actions/setup-go@v7 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Spec matches the handler annotations + run: bash scripts/check-openapi.sh + lint: name: Lint runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 9e0931c5..c85635e6 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,14 @@ lint-ci: lint-new @echo "Running staticcheck..." @GOWORK=off go run honnef.co/go/tools/cmd/staticcheck@latest ./... +## release-branch: build a release branch that can merge into main (VERSION=v0.5.0) +## Releases are squash-merged, so main ends up single-parent and git stops seeing +## that develop contains it — the next release then reports conflicts that are not +## disagreements. This carries the ancestry inside the branch instead, so it merges +## cleanly regardless of which button is pressed. Add PUSH=--push when ready. +release-branch: + @bash scripts/release-branch.sh $(VERSION) $(PUSH) + ## fmt: Format code fmt: @echo "Formatting code..." @@ -118,21 +126,46 @@ tidy: @echo "Tidying dependencies..." $(GOMOD) tidy -## swagger: Generate OpenAPI documentation using swag -swagger: +## swagger: Regenerate api/openapi/swagger.yaml from the handler annotations. +## The spec is a GENERATED artifact — never hand-edit it. The UI generates its +## API types from the committed file, and scripts/check-openapi.sh fails CI when +## the annotations, the spec and the registered routes stop agreeing. +## +## Previously this target printed "swag not installed" and exited 0, so a +## regeneration that never happened looked exactly like one that succeeded. +swagger: swagger-install @echo "Generating Swagger documentation..." - @if command -v swag >/dev/null 2>&1; then \ - GOWORK=off swag init --generalInfo cmd/server/main.go --output api/openapi --outputTypes yaml --parseDependency; \ - echo "" >> api/openapi/swagger.yaml; \ - echo "Swagger docs generated in api/openapi/"; \ - else \ - echo "swag not installed. Run: make swagger-install"; \ - fi - -## swagger-install: Install swag CLI tool + @# --parseDependency walks into dependency packages — the asset-type enum + @# descriptions come from github.com/openctemio/ctis, not from this module, + @# and without the flag swag fails outright on json.RawMessage. Download + @# first so it is reading source rather than guessing. + @# + @# This does NOT make swag reproducible: a clean runner still drops + @# `format: int64` from some map[string]int64 fields that a developer + @# machine emits, with the same swag, the same Go and a warm cache. That is + @# why the gate compares sets of operations rather than bytes. See + @# tools/lint/openapicontract. + @GOWORK=off go mod download + @GOWORK=off $(SWAG) init --generalInfo cmd/server/main.go --output api/openapi --outputTypes yaml --parseDependency + @echo "Swagger docs generated in api/openapi/" + +## swagger-check: Fail if the annotations, the spec and the routes disagree. +## Compares SETS of operations, not the bytes of the generated file — swag is +## not reproducible enough across environments for a byte diff to hold. See +## tools/lint/openapicontract for why. +swagger-check: + @bash scripts/check-openapi.sh + +## swagger-install: Install the pinned swag CLI (no-op if already present). +## Pinned: an upstream release must not be able to change the committed contract +## or turn every PR red. Bump here and in scripts/check-openapi.sh together. +SWAG_VERSION ?= v1.16.4 +SWAG := $(shell command -v swag 2>/dev/null || echo "$$(go env GOPATH)/bin/swag") swagger-install: - @echo "Installing swag..." - go install github.com/swaggo/swag/cmd/swag@latest + @if ! "$(SWAG)" --version 2>/dev/null | grep -qF "$(SWAG_VERSION)"; then \ + echo "Installing swag $(SWAG_VERSION)..."; \ + GOWORK=off GOFLAGS=-mod=mod go install github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION); \ + fi ## clean: Clean build artifacts clean: diff --git a/api/openapi/swagger.yaml b/api/openapi/swagger.yaml index c6cc0286..42af83cc 100644 --- a/api/openapi/swagger.yaml +++ b/api/openapi/swagger.yaml @@ -204,7 +204,7 @@ definitions: - defi_protocol - token - blockchain - - other + - unclassified type: string x-enum-comments: AssetTypeAPI: API endpoint (REST, GraphQL, gRPC) @@ -226,13 +226,13 @@ definitions: AssetTypeMobileApp: Mobile application (iOS, Android) AssetTypeNetwork: Network segment AssetTypeOpenPort: Individual open ports from Naabu - AssetTypeOther: Catch-all for unknown types AssetTypeServer: Server machine AssetTypeServerless: Lambda, Cloud Functions, Azure Functions AssetTypeService: Network service (SSH, SMTP, FTP, DNS, etc.) AssetTypeServiceAccount: Service account AssetTypeStorage: S3, GCS, Azure Blob AssetTypeSubnet: Network subnet + AssetTypeUnclassified: Assets that have not been classified yet AssetTypeVPC: Virtual Private Cloud AssetTypeWebApplication: Web application (SaaS, internal apps) AssetTypeWebsite: Public-facing website @@ -276,7 +276,7 @@ definitions: - "" - "" - "" - - Catch-all for unknown types + - Assets that have not been classified yet x-enum-varnames: - AssetTypeDomain - AssetTypeSubdomain @@ -317,7 +317,7 @@ definitions: - AssetTypeDeFiProtocol - AssetTypeToken - AssetTypeBlockchain - - AssetTypeOther + - AssetTypeUnclassified ctis.Attachment: properties: artifact_location: @@ -873,6 +873,11 @@ definitions: description: description: Detailed description type: string + evidence: + description: |- + Evidence is the scanner's raw proof/output for this finding (e.g. a Nessus + plugin_output, a probe response). Free text; redact secrets before display. + type: string exposure: allOf: - $ref: '#/definitions/ctis.FindingExposure' @@ -917,6 +922,13 @@ definitions: allOf: - $ref: '#/definitions/ctis.MisconfigurationDetails' description: Misconfiguration-specific details + network: + allOf: + - $ref: '#/definitions/ctis.NetworkLocation' + description: |- + Network location (for network/host findings, e.g. Nessus/Tenable): the + port/protocol/service the finding was observed on. Distinct from the + code-centric Location above. occurrence_count: description: Occurrence count - number of times this result was observed (SARIF occurrenceCount) @@ -1076,6 +1088,7 @@ definitions: enum: - open - resolved + - suppressed - false_positive - accepted_risk - in_progress @@ -1083,6 +1096,7 @@ definitions: x-enum-varnames: - FindingStatusOpen - FindingStatusResolved + - FindingStatusSuppressed - FindingStatusFalsePositive - FindingStatusAcceptedRisk - FindingStatusInProgress @@ -1306,6 +1320,24 @@ definitions: description: Total volume USD type: number type: object + ctis.NetworkLocation: + properties: + host: + description: |- + Host the finding was observed on (IP or hostname). Optional when the + finding already references its host asset via AssetRef/AssetValue. + type: string + port: + description: Port number (0 = not port-specific / general host finding). + type: integer + protocol: + description: 'Transport protocol: tcp, udp.' + type: string + service: + description: 'Service / application protocol on the port: https, ssh, smb, + mysql, etc.' + type: string + type: object ctis.OracleManipulationIssue: properties: manipulation_method: @@ -2029,8 +2061,15 @@ definitions: description: Affected CPE type: string cve_id: - description: CVE ID + description: CVE ID (primary, for single-CVE findings and backward compatibility) type: string + cve_ids: + description: |- + CVE IDs (a finding may map to multiple CVEs — common for network + vulnerability scanners like Nessus/Tenable that group CVEs per plugin). + items: + type: string + type: array cvss_score: description: CVSS score type: number @@ -2119,6 +2158,12 @@ definitions: type: integer description: Vendor-specific severity mapping (vendor -> severity level 1-5) type: object + vpr_score: + description: |- + VPR (Tenable Vulnerability Priority Rating), 0.0–10.0. Tenable's own + dynamic priority score, distinct from CVSS/EPSS; carried through so + prioritisation can use the source scanner's rating when present. + type: number vuln_status: description: 'Vulnerability status: affected, fixed, under_investigation, will_not_fix' @@ -2350,7 +2395,7 @@ definitions: properties: items: items: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' + $ref: '#/definitions/github_com_openctemio_api_internal_app_integration.CredentialItem' type: array page: type: integer @@ -2361,19 +2406,11 @@ definitions: total_pages: type: integer type: object - github_com_openctemio_api_internal_app.GetModuleLimitOutput: - properties: - limit: - format: int64 - type: integer - unlimited: - type: boolean - type: object github_com_openctemio_api_internal_app.GetNotificationEventsResult: properties: data: items: - $ref: '#/definitions/github_com_openctemio_api_internal_app.NotificationEventEntry' + $ref: '#/definitions/github_com_openctemio_api_internal_app_integration.NotificationEventEntry' type: array limit: type: integer @@ -2382,7 +2419,94 @@ definitions: total: type: integer type: object - github_com_openctemio_api_internal_app.IdentityExposure: + github_com_openctemio_api_internal_app.IdentityListResult: + properties: + items: + items: + $ref: '#/definitions/github_com_openctemio_api_internal_app_integration.IdentityExposure' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + total_pages: + type: integer + type: object + github_com_openctemio_api_internal_app.ProviderInfo: + properties: + enabled: + type: boolean + id: + type: string + name: + type: string + type: object + github_com_openctemio_api_internal_app.SessionInfo: + properties: + created_at: + type: string + id: + type: string + ip_address: + type: string + is_current: + type: boolean + last_activity_at: + type: string + user_agent: + type: string + type: object + github_com_openctemio_api_internal_app_ingest.BaselineDiffOutput: + properties: + base_branch_scanned: + description: |- + BaseBranchKnown is false when the base branch has no scan history yet + (then everything is treated as new). + type: boolean + new_fingerprints: + description: New are fingerprints NOT already open on the base branch (introduced + by the PR). + items: + type: string + type: array + pre_existing_fingerprints: + description: PreExisting are fingerprints already open on the base branch + (tech debt). + items: + type: string + type: array + type: object + github_com_openctemio_api_internal_app_integration.CredentialItem: + properties: + credential_type: + type: string + details: + additionalProperties: {} + type: object + first_seen_at: + type: string + id: + type: string + identifier: + type: string + is_revoked: + type: boolean + is_verified: + type: boolean + last_seen_at: + type: string + secret_value: + type: string + severity: + type: string + source: + type: string + state: + type: string + type: object + github_com_openctemio_api_internal_app_integration.IdentityExposure: properties: credential_types: items: @@ -2412,22 +2536,7 @@ definitions: description: count by state type: object type: object - github_com_openctemio_api_internal_app.IdentityListResult: - properties: - items: - items: - $ref: '#/definitions/github_com_openctemio_api_internal_app.IdentityExposure' - type: array - page: - type: integer - page_size: - type: integer - total: - type: integer - total_pages: - type: integer - type: object - github_com_openctemio_api_internal_app.NotificationEventEntry: + github_com_openctemio_api_internal_app_integration.NotificationEventEntry: properties: aggregate_id: type: string @@ -2457,7 +2566,7 @@ definitions: type: integer send_results: items: - $ref: '#/definitions/github_com_openctemio_api_internal_app.NotificationEventSendResult' + $ref: '#/definitions/github_com_openctemio_api_internal_app_integration.NotificationEventSendResult' type: array severity: type: string @@ -2468,7 +2577,7 @@ definitions: url: type: string type: object - github_com_openctemio_api_internal_app.NotificationEventSendResult: + github_com_openctemio_api_internal_app_integration.NotificationEventSendResult: properties: error: type: string @@ -2485,31 +2594,90 @@ definitions: status: type: string type: object - github_com_openctemio_api_internal_app.ProviderInfo: + github_com_openctemio_api_internal_app_scancoverage.CoverageStats: properties: - enabled: - type: boolean - id: - type: string - name: + coverage_percent: + description: CoveragePercent = CoveredInWindow / TotalScannable * 100 (0 when + none). + type: number + covered_in_window: + description: CoveredInWindow were dispatched within WindowDays. + type: integer + critical_never_scanned: + description: 'CriticalNeverScanned is the headline risk: critical assets never + covered.' + type: integer + critical_uncovered: + description: CriticalUncovered are critical assets either never scanned or + stale. + type: integer + never_scanned: + description: NeverScanned have no coverage cursor row yet. + type: integer + oldest_dispatched_at: + description: |- + OldestDispatchedAt is the least-recently covered asset's timestamp (nil if + nothing has been dispatched yet). type: string + stale: + description: Stale were dispatched, but longer ago than WindowDays. + type: integer + total_scannable: + description: TotalScannable is the count of active, network-scannable assets. + type: integer + window_days: + description: WindowDays is the freshness window the stats were computed against. + type: integer type: object - github_com_openctemio_api_internal_app.SessionInfo: + github_com_openctemio_api_pkg_apierror.Code: + enum: + - BAD_REQUEST + - UNAUTHORIZED + - FORBIDDEN + - NOT_FOUND + - CONFLICT + - UNPROCESSABLE_ENTITY + - INTERNAL_ERROR + - SERVICE_UNAVAILABLE + - VALIDATION_FAILED + - RATE_LIMIT_EXCEEDED + type: string + x-enum-varnames: + - CodeBadRequest + - CodeUnauthorized + - CodeForbidden + - CodeNotFound + - CodeConflict + - CodeUnprocessableEntity + - CodeInternalError + - CodeServiceUnavailable + - CodeValidationFailed + - CodeRateLimitExceeded + github_com_openctemio_api_pkg_apierror.Error: properties: - created_at: - type: string - id: + code: + allOf: + - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Code' + description: Machine-readable error code + details: + description: Additional error details (optional) + message: + description: Human-readable error message type: string - ip_address: + type: object + github_com_openctemio_api_pkg_apierror.Response: + properties: + code: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Code' + details: {} + error: type: string - is_current: - type: boolean - last_activity_at: + message: type: string - user_agent: + request_id: type: string type: object - github_com_openctemio_api_internal_domain_audit.Changes: + github_com_openctemio_api_pkg_domain_audit.Changes: properties: after: additionalProperties: {} @@ -2518,7 +2686,7 @@ definitions: additionalProperties: {} type: object type: object - github_com_openctemio_api_internal_domain_component.ComponentStats: + github_com_openctemio_api_pkg_domain_component.ComponentStats: properties: cisa_kev_components: type: integer @@ -2546,7 +2714,7 @@ definitions: vulnerable_components: type: integer type: object - github_com_openctemio_api_internal_domain_component.EcosystemStats: + github_com_openctemio_api_pkg_domain_component.EcosystemStats: properties: ecosystem: type: string @@ -2559,7 +2727,7 @@ definitions: vulnerable: type: integer type: object - github_com_openctemio_api_internal_domain_component.LicenseStats: + github_com_openctemio_api_pkg_domain_component.LicenseStats: properties: category: description: permissive, copyleft, weak-copyleft, proprietary, public-domain, @@ -2581,7 +2749,7 @@ definitions: description: Link to license text (SPDX URL) type: string type: object - github_com_openctemio_api_internal_domain_component.VulnerableComponent: + github_com_openctemio_api_pkg_domain_component.VulnerableComponent: properties: critical_count: description: Vulnerability breakdown @@ -2609,7 +2777,7 @@ definitions: version: type: string type: object - github_com_openctemio_api_internal_domain_credential.ImportError: + github_com_openctemio_api_pkg_domain_credential.ImportError: properties: error: type: string @@ -2618,7 +2786,7 @@ definitions: index: type: integer type: object - github_com_openctemio_api_internal_domain_credential.ImportItemResult: + github_com_openctemio_api_pkg_domain_credential.ImportItemResult: properties: action: description: imported, updated, reactivated, skipped, error @@ -2633,15 +2801,15 @@ definitions: reason: type: string type: object - github_com_openctemio_api_internal_domain_credential.ImportResult: + github_com_openctemio_api_pkg_domain_credential.ImportResult: properties: details: items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_credential.ImportItemResult' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_credential.ImportItemResult' type: array errors: items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_credential.ImportError' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_credential.ImportError' type: array imported: type: integer @@ -2650,11 +2818,11 @@ definitions: skipped: type: integer summary: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_credential.ImportSummary' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_credential.ImportSummary' updated: type: integer type: object - github_com_openctemio_api_internal_domain_credential.ImportSummary: + github_com_openctemio_api_pkg_domain_credential.ImportSummary: properties: critical_count: type: integer @@ -2667,7 +2835,7 @@ definitions: total_processed: type: integer type: object - github_com_openctemio_api_internal_domain_group.GroupSettings: + github_com_openctemio_api_pkg_domain_group.GroupSettings: properties: allow_self_join: type: boolean @@ -2676,7 +2844,7 @@ definitions: require_approval: type: boolean type: object - github_com_openctemio_api_internal_domain_group.NotificationConfig: + github_com_openctemio_api_pkg_domain_group.NotificationConfig: properties: notify_critical: type: boolean @@ -2695,7 +2863,24 @@ definitions: weekly_digest: type: boolean type: object - github_com_openctemio_api_internal_domain_scanprofile.FindingCounts: + github_com_openctemio_api_pkg_domain_remediation.Group: + properties: + asset_count: + type: integer + finding_count: + type: integer + fix_available: + type: boolean + key: + type: string + severity_counts: + additionalProperties: + type: integer + type: object + title: + type: string + type: object + github_com_openctemio_api_pkg_domain_scanprofile.FindingCounts: properties: critical: type: integer @@ -2710,7 +2895,7 @@ definitions: total: type: integer type: object - github_com_openctemio_api_internal_domain_scanprofile.GateBreach: + github_com_openctemio_api_pkg_domain_scanprofile.GateBreach: properties: actual: type: integer @@ -2720,20 +2905,20 @@ definitions: description: '"critical", "high", "medium", "total"' type: string type: object - github_com_openctemio_api_internal_domain_scanprofile.QualityGateResult: + github_com_openctemio_api_pkg_domain_scanprofile.QualityGateResult: properties: breaches: items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_scanprofile.GateBreach' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_scanprofile.GateBreach' type: array counts: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_scanprofile.FindingCounts' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_scanprofile.FindingCounts' passed: type: boolean reason: type: string type: object - github_com_openctemio_api_internal_domain_templatesource.GitSourceConfig: + github_com_openctemio_api_pkg_domain_templatesource.GitSourceConfig: properties: auth_type: description: none, ssh, token, oauth @@ -2748,7 +2933,7 @@ definitions: description: https://github.com/org/repo type: string type: object - github_com_openctemio_api_internal_domain_templatesource.HTTPSourceConfig: + github_com_openctemio_api_pkg_domain_templatesource.HTTPSourceConfig: properties: auth_type: description: none, bearer, basic, api_key @@ -2763,7 +2948,7 @@ definitions: url: type: string type: object - github_com_openctemio_api_internal_domain_templatesource.S3SourceConfig: + github_com_openctemio_api_pkg_domain_templatesource.S3SourceConfig: properties: auth_type: description: keys, sts_role @@ -2785,18 +2970,42 @@ definitions: description: For cross-account type: string type: object - github_com_openctemio_api_internal_domain_vulnerability.ArtifactLocation: + github_com_openctemio_api_pkg_domain_tenant.RiskLevelConfig: + properties: + critical_min: + type: integer + high_min: + type: integer + low_min: + type: integer + medium_min: + type: integer + type: object + github_com_openctemio_api_pkg_domain_vulnerability.ActiveCVEStats: + properties: + by_severity: + additionalProperties: + type: integer + type: object + exploit_available_count: + type: integer + kev_count: + type: integer + total: + type: integer + type: object + github_com_openctemio_api_pkg_domain_vulnerability.ArtifactLocation: properties: uri: type: string uri_base_id: type: string type: object - github_com_openctemio_api_internal_domain_vulnerability.Attachment: + github_com_openctemio_api_pkg_domain_vulnerability.Attachment: properties: artifact_location: allOf: - - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.ArtifactLocation' + - $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.ArtifactLocation' description: Location of the artifact description: description: Human-readable description @@ -2804,19 +3013,19 @@ definitions: rectangles: description: Highlight areas (for images) items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.Rectangle' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.Rectangle' type: array regions: description: Relevant regions in the artifact items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.FindingLocation' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.FindingLocation' type: array type: allOf: - - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.AttachmentType' + - $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.AttachmentType' description: Attachment type for UI categorization type: object - github_com_openctemio_api_internal_domain_vulnerability.AttachmentType: + github_com_openctemio_api_pkg_domain_vulnerability.AttachmentType: enum: - evidence - screenshot @@ -2846,7 +3055,7 @@ definitions: - AttachmentTypeReference - AttachmentTypeCode - AttachmentTypeOther - github_com_openctemio_api_internal_domain_vulnerability.FindingLocation: + github_com_openctemio_api_pkg_domain_vulnerability.FindingLocation: properties: branch: type: string @@ -2859,7 +3068,7 @@ definitions: end_line: type: integer logical_location: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.LogicalLocation' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.LogicalLocation' message: description: Optional description of why this location is relevant type: string @@ -2872,7 +3081,7 @@ definitions: start_line: type: integer type: object - github_com_openctemio_api_internal_domain_vulnerability.LogicalLocation: + github_com_openctemio_api_pkg_domain_vulnerability.LogicalLocation: properties: fully_qualified_name: type: string @@ -2882,7 +3091,7 @@ definitions: name: type: string type: object - github_com_openctemio_api_internal_domain_vulnerability.Rectangle: + github_com_openctemio_api_pkg_domain_vulnerability.Rectangle: properties: bottom: type: number @@ -2893,10 +3102,10 @@ definitions: top: type: number type: object - github_com_openctemio_api_internal_domain_vulnerability.StackFrame: + github_com_openctemio_api_pkg_domain_vulnerability.StackFrame: properties: location: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.FindingLocation' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.FindingLocation' module: type: string parameters: @@ -2906,62 +3115,29 @@ definitions: thread_id: type: integer type: object - github_com_openctemio_api_internal_domain_vulnerability.StackTrace: + github_com_openctemio_api_pkg_domain_vulnerability.StackTrace: properties: frames: items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.StackFrame' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.StackFrame' type: array message: type: string type: object - github_com_openctemio_api_pkg_apierror.Code: - enum: - - BAD_REQUEST - - UNAUTHORIZED - - FORBIDDEN - - NOT_FOUND - - CONFLICT - - UNPROCESSABLE_ENTITY - - INTERNAL_ERROR - - SERVICE_UNAVAILABLE - - VALIDATION_FAILED - - RATE_LIMIT_EXCEEDED - type: string - x-enum-varnames: - - CodeBadRequest - - CodeUnauthorized - - CodeForbidden - - CodeNotFound - - CodeConflict - - CodeUnprocessableEntity - - CodeInternalError - - CodeServiceUnavailable - - CodeValidationFailed - - CodeRateLimitExceeded - github_com_openctemio_api_pkg_apierror.Error: - properties: - code: - allOf: - - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Code' - description: Machine-readable error code - details: - description: Additional error details (optional) - message: - description: Human-readable error message - type: string - type: object - github_com_openctemio_api_pkg_apierror.Response: + github_com_openctemio_api_pkg_pagination.Result-internal_infra_http_handler_NotificationResponse: properties: - code: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Code' - details: {} - error: - type: string - message: - type: string - request_id: - type: string + data: + items: + $ref: '#/definitions/internal_infra_http_handler.NotificationResponse' + type: array + page: + type: integer + per_page: + type: integer + total: + type: integer + total_pages: + type: integer type: object github_com_openctemio_api_pkg_pagination.Result-internal_infra_http_handler_OutboxEntryResponse: properties: @@ -3103,6 +3279,17 @@ definitions: package: type: string type: object + internal_infra_http_handler.AgentConfigTemplatesResponse: + properties: + cli: + type: string + docker: + type: string + env: + type: string + yaml: + type: string + type: object internal_infra_http_handler.AgentDisableRequest: properties: reason: @@ -3192,6 +3379,42 @@ definitions: maxLength: 500 type: string type: object + internal_infra_http_handler.AgentStatsResponse: + properties: + active_jobs: + type: integer + by_execution_mode: + additionalProperties: + type: integer + type: object + by_health: + additionalProperties: + type: integer + type: object + by_status: + additionalProperties: + type: integer + type: object + by_type: + additionalProperties: + type: integer + type: object + online_active: + type: integer + total: + type: integer + type: object + internal_infra_http_handler.AssetBriefResponse: + properties: + id: + type: string + name: + type: string + status: + type: string + type: + type: string + type: object internal_infra_http_handler.AssetBulkStatusRequest: properties: asset_ids: @@ -3302,30 +3525,29 @@ definitions: total_findings: type: integer type: object - internal_infra_http_handler.AssetOwnerResponse: + internal_infra_http_handler.AssetResponse: properties: - asset_id: - type: string - assigned_at: - type: string - assigned_by: + category: type: string - group_id: + compliance_scope: + description: CTEM + items: + type: string + type: array + created_at: type: string - id: + criticality: type: string - ownership_type: + data_classification: type: string - user_id: + description: type: string - type: object - internal_infra_http_handler.AssetResponse: - properties: - created_at: + discovered_at: type: string - criticality: + discovery_source: + description: Discovery type: string - description: + discovery_tool: type: string exposure: type: string @@ -3333,17 +3555,43 @@ definitions: type: string finding_count: type: integer + finding_severity_counts: + $ref: '#/definitions/internal_infra_http_handler.FindingSeverityResponse' first_seen: + description: Timestamps type: string id: type: string + is_internet_accessible: + type: boolean last_seen: type: string - metadata: - additionalProperties: {} - type: object + last_synced_at: + type: string + lifecycle_paused_until: + description: |- + Lifecycle state. Null LifecyclePausedUntil is the common case + (most assets have never been snoozed). ManualStatusOverride = + true tells the UI that the status was set by an operator and + the background worker is not allowed to change it. + type: string + manual_status_override: + type: boolean name: type: string + owner_ref: + type: string + parent_id: + type: string + phi_data_exposed: + type: boolean + pii_data_exposed: + type: boolean + primary_owner: + $ref: '#/definitions/internal_infra_http_handler.OwnerBriefResponse' + properties: + additionalProperties: {} + type: object provider: type: string risk_score: @@ -3352,6 +3600,11 @@ definitions: type: string status: type: string + sub_type: + type: string + sync_status: + description: Sync + type: string tags: items: type: string @@ -3403,6 +3656,10 @@ definitions: type: string state_changed_at: type: string + technologies: + items: + type: string + type: array tenant_id: type: string tls_enabled: @@ -3435,6 +3692,10 @@ definitions: additionalProperties: type: integer type: object + by_sub_type: + additionalProperties: + type: integer + type: object by_type: additionalProperties: type: integer @@ -3462,6 +3723,10 @@ definitions: additionalProperties: type: integer type: object + by_sub_type: + additionalProperties: + type: integer + type: object by_type: additionalProperties: type: integer @@ -3469,8 +3734,13 @@ definitions: findings_total: type: integer high_risk_count: - description: Assets with risk_score >= 70 type: integer + metadata_counts: + additionalProperties: + additionalProperties: + type: integer + type: object + type: object risk_score_avg: type: number total: @@ -3535,29 +3805,71 @@ definitions: type: object internal_infra_http_handler.AssetWithRepositoryResponse: properties: + category: + type: string + compliance_scope: + description: CTEM + items: + type: string + type: array created_at: type: string criticality: type: string + data_classification: + type: string description: type: string + discovered_at: + type: string + discovery_source: + description: Discovery + type: string + discovery_tool: + type: string exposure: type: string external_id: type: string finding_count: type: integer + finding_severity_counts: + $ref: '#/definitions/internal_infra_http_handler.FindingSeverityResponse' first_seen: + description: Timestamps type: string id: type: string + is_internet_accessible: + type: boolean last_seen: type: string - metadata: - additionalProperties: {} - type: object + last_synced_at: + type: string + lifecycle_paused_until: + description: |- + Lifecycle state. Null LifecyclePausedUntil is the common case + (most assets have never been snoozed). ManualStatusOverride = + true tells the UI that the status was set by an operator and + the background worker is not allowed to change it. + type: string + manual_status_override: + type: boolean name: type: string + owner_ref: + type: string + parent_id: + type: string + phi_data_exposed: + type: boolean + pii_data_exposed: + type: boolean + primary_owner: + $ref: '#/definitions/internal_infra_http_handler.OwnerBriefResponse' + properties: + additionalProperties: {} + type: object provider: type: string repository: @@ -3568,6 +3880,11 @@ definitions: type: string status: type: string + sub_type: + type: string + sync_status: + description: Sync + type: string tags: items: type: string @@ -3608,6 +3925,59 @@ definitions: required: - permission_set_id type: object + internal_infra_http_handler.AttackPathScoreResponse: + properties: + asset_id: + type: string + asset_type: + type: string + criticality: + type: string + exposure: + type: string + finding_count: + type: integer + is_crown_jewel: + type: boolean + is_entry_point: + type: boolean + is_protected: + type: boolean + name: + type: string + path_score: + type: number + reachable_from: + type: integer + risk_score: + type: integer + type: object + internal_infra_http_handler.AttackPathScoringResponse: + properties: + summary: + $ref: '#/definitions/internal_infra_http_handler.AttackPathSummaryResponse' + top_assets: + items: + $ref: '#/definitions/internal_infra_http_handler.AttackPathScoreResponse' + type: array + type: object + internal_infra_http_handler.AttackPathSummaryResponse: + properties: + critical_reachable: + type: integer + crown_jewels_at_risk: + type: integer + entry_points: + type: integer + has_relationship_data: + type: boolean + max_depth: + type: integer + reachable_assets: + type: integer + total_paths: + type: integer + type: object internal_infra_http_handler.AttackSurfaceStatsResponse: description: Attack surface statistics response properties: @@ -3676,7 +4046,7 @@ definitions: actor_ip: type: string changes: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_audit.Changes' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_audit.Changes' id: type: string message: @@ -3729,6 +4099,16 @@ definitions: registration_enabled: type: boolean type: object + internal_infra_http_handler.AuthProvidersResponse: + properties: + social: + $ref: '#/definitions/internal_infra_http_handler.SocialProviders' + sso_env_entra_enabled: + description: |- + SSOEnvEntraEnabled reports whether the platform-wide (env-based) + Microsoft Entra ID SSO fallback is usable (SSO_ENTRA_* configured). + type: boolean + type: object internal_infra_http_handler.AuthorizeResponse: properties: authorization_url: @@ -3756,6 +4136,17 @@ definitions: - client_secret - tenant_id type: object + internal_infra_http_handler.BaselineDiffRequest: + properties: + base_branch: + type: string + fingerprints: + items: + type: string + type: array + repository: + type: string + type: object internal_infra_http_handler.BasicAuthDataRequest: properties: password: @@ -3788,39 +4179,8 @@ definitions: $ref: '#/definitions/internal_infra_http_handler.TenantModulesResponse' permissions: $ref: '#/definitions/internal_infra_http_handler.BootstrapPermissions' - subscription: - $ref: '#/definitions/internal_infra_http_handler.SubscriptionResponse' - type: object - internal_infra_http_handler.BootstrapTokenResponse: - properties: - created_at: - type: string - created_by: - type: string - current_uses: - type: integer - description: - type: string - expires_at: - type: string - id: - type: string - max_uses: - type: integer - required_capabilities: - items: - type: string - type: array - required_region: - type: string - required_tools: - items: - type: string - type: array - status: - type: string - token_prefix: - type: string + risk_levels: + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_tenant.RiskLevelConfig' type: object internal_infra_http_handler.BranchResponse: properties: @@ -3914,12 +4274,26 @@ definitions: maxItems: 100 minItems: 1 type: array + operator_approved: + description: OperatorApproved bypasses the BulkGuard per-request ceiling. + type: boolean user_id: type: string required: - finding_ids - user_id type: object + internal_infra_http_handler.BulkDeleteExclusionsRequest: + properties: + exclusion_ids: + items: + type: string + maxItems: 100 + minItems: 1 + type: array + required: + - exclusion_ids + type: object internal_infra_http_handler.BulkDeleteRequest: properties: group_ids: @@ -3930,6 +4304,28 @@ definitions: required: - group_ids type: object + internal_infra_http_handler.BulkDeleteSchedulesRequest: + properties: + schedule_ids: + items: + type: string + maxItems: 100 + minItems: 1 + type: array + required: + - schedule_ids + type: object + internal_infra_http_handler.BulkDeleteTargetsRequest: + properties: + target_ids: + items: + type: string + maxItems: 100 + minItems: 1 + type: array + required: + - target_ids + type: object internal_infra_http_handler.BulkIngestRequest: properties: exposures: @@ -4028,6 +4424,11 @@ definitions: maxItems: 100 minItems: 1 type: array + operator_approved: + description: |- + OperatorApproved bypasses the BulkGuard per-request ceiling. + Requires owner/admin role at route-permission level. see the route permission guide. + type: boolean resolution: maxLength: 1000 type: string @@ -4050,6 +4451,11 @@ definitions: ransomware_use: type: string type: object + internal_infra_http_handler.CTISIngestRequest: + properties: + report: + $ref: '#/definitions/ctis.Report' + type: object internal_infra_http_handler.CallbackRequest: properties: code: @@ -4140,6 +4546,16 @@ definitions: status: type: string type: object + internal_infra_http_handler.CheckScopeRequest: + properties: + asset_type: + type: string + value: + type: string + required: + - asset_type + - value + type: object internal_infra_http_handler.ChunkIngestRequest: properties: chunk_index: @@ -4182,34 +4598,7 @@ definitions: status: type: string type: object - internal_infra_http_handler.ClaimJobRequest: - properties: - capabilities: - items: - type: string - maxItems: 20 - minItems: 1 - type: array - tools: - items: - type: string - maxItems: 20 - minItems: 1 - type: array - required: - - capabilities - - tools - type: object - internal_infra_http_handler.ClaimJobResponse: - properties: - auth_token: - type: string - job: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobResponse' - no_job_available: - type: boolean - type: object - internal_infra_http_handler.ClassifyFindingRequest: + internal_infra_http_handler.ClassifyFindingRequest: properties: cve_id: maxLength: 20 @@ -4430,6 +4819,12 @@ definitions: maxLength: 255 minLength: 1 type: string + owner_ref: + maxLength: 500 + type: string + properties: + additionalProperties: {} + type: object scope: type: string tags: @@ -4480,6 +4875,11 @@ definitions: type: string service_type: type: string + technologies: + items: + type: string + maxItems: 50 + type: array tls_enabled: type: boolean tls_version: @@ -4493,44 +4893,6 @@ definitions: - protocol - service_type type: object - internal_infra_http_handler.CreateBootstrapTokenRequest: - properties: - description: - maxLength: 500 - type: string - expires_in_hours: - maximum: 168 - minimum: 1 - type: integer - max_uses: - maximum: 100 - minimum: 1 - type: integer - required_capabilities: - items: - type: string - maxItems: 20 - type: array - required_region: - maxLength: 50 - type: string - required_tools: - items: - type: string - maxItems: 20 - type: array - required: - - description - - expires_in_hours - - max_uses - type: object - internal_infra_http_handler.CreateBootstrapTokenResponse: - properties: - raw_token: - type: string - token: - $ref: '#/definitions/internal_infra_http_handler.BootstrapTokenResponse' - type: object internal_infra_http_handler.CreateBranchRequest: properties: is_default: @@ -4760,8 +5122,10 @@ definitions: maxLength: 100 minLength: 2 type: string + notification_config: + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_group.NotificationConfig' settings: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_group.GroupSettings' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_group.GroupSettings' slug: maxLength: 100 minLength: 2 @@ -4795,6 +5159,10 @@ definitions: - notification example: scm type: string + config: + additionalProperties: {} + description: Config holds non-sensitive provider settings (e.g. Tenable execution_mode/engine). + type: object credentials: example: YOUR_TOKEN_HERE maxLength: 5000 @@ -4904,52 +5272,6 @@ definitions: - set_type - slug type: object - internal_infra_http_handler.CreatePlatformAgentRequest: - properties: - capabilities: - items: - type: string - maxItems: 20 - minItems: 1 - type: array - description: - maxLength: 1000 - type: string - labels: - additionalProperties: - type: string - type: object - max_concurrent_jobs: - maximum: 50 - minimum: 1 - type: integer - name: - maxLength: 255 - minLength: 1 - type: string - region: - maxLength: 50 - type: string - tools: - items: - type: string - maxItems: 20 - minItems: 1 - type: array - required: - - capabilities - - max_concurrent_jobs - - name - - region - - tools - type: object - internal_infra_http_handler.CreatePlatformAgentResponse: - properties: - agent: - $ref: '#/definitions/internal_infra_http_handler.PlatformAgentResponse' - api_key: - type: string - type: object internal_infra_http_handler.CreateRepositoryAssetRequest: properties: clone_url: @@ -5138,6 +5460,12 @@ definitions: type: object internal_infra_http_handler.CreateScanRequest: properties: + agent_preference: + enum: + - auto + - tenant + - platform + type: string asset_group_id: description: Single asset group (legacy) type: string @@ -5149,12 +5477,22 @@ definitions: description: maxLength: 1000 type: string + max_retries: + maximum: 10 + minimum: 0 + type: integer name: maxLength: 200 minLength: 1 type: string pipeline_id: type: string + profile_id: + type: string + retry_backoff_seconds: + maximum: 86400 + minimum: 10 + type: integer run_on_tenant_runner: type: boolean scan_type: @@ -5196,6 +5534,10 @@ definitions: type: array targets_per_job: type: integer + timeout_seconds: + maximum: 86400 + minimum: 30 + type: integer timezone: maxLength: 50 type: string @@ -5336,15 +5678,15 @@ definitions: enabled: type: boolean git_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.GitSourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.GitSourceConfig' http_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.HTTPSourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.HTTPSourceConfig' name: maxLength: 255 minLength: 1 type: string s3_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.S3SourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.S3SourceConfig' source_type: enum: - git @@ -5484,19 +5826,27 @@ definitions: internal_infra_http_handler.CredentialContextReq: properties: domain: + maxLength: 255 type: string email: + maxLength: 254 type: string extra: additionalProperties: {} type: object ip_address: + description: IPv6 max + maxLength: 45 type: string line_number: + maximum: 10000000 + minimum: 0 type: integer user_agent: + maxLength: 500 type: string username: + maxLength: 255 type: string type: object internal_infra_http_handler.CredentialImportItem: @@ -5580,10 +5930,13 @@ definitions: description: ISO8601 format type: string name: + maxLength: 255 type: string type: + maxLength: 50 type: string url: + maxLength: 2000 type: string required: - type @@ -5648,6 +6001,10 @@ definitions: properties: assets: $ref: '#/definitions/internal_infra_http_handler.AssetStats' + finding_trend: + items: + $ref: '#/definitions/internal_infra_http_handler.FindingTrendPoint' + type: array findings: $ref: '#/definitions/internal_infra_http_handler.FindingStats' recent_activity: @@ -5765,20 +6122,28 @@ definitions: internal_infra_http_handler.DedupKeyRequest: properties: branch: + maxLength: 255 type: string breach_date: + maxLength: 30 type: string breach_name: + maxLength: 255 type: string commit_hash: + maxLength: 64 type: string file_path: + maxLength: 1000 type: string paste_id: + maxLength: 255 type: string repository: + maxLength: 500 type: string source_url: + maxLength: 2000 type: string type: object internal_infra_http_handler.DiscoveredURLResult: @@ -5800,11 +6165,6 @@ definitions: url: type: string type: object - internal_infra_http_handler.EISIngestRequest: - properties: - report: - $ref: '#/definitions/ctis.Report' - type: object internal_infra_http_handler.EffectivePermissionsResponse: properties: group_count: @@ -6087,7 +6447,7 @@ definitions: description: Full user info attachments: items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.Attachment' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.Attachment' type: array attack_prerequisites: type: string @@ -6097,17 +6457,25 @@ definitions: type: string comments_count: type: integer + compliance_control_description: + type: string compliance_control_id: type: string + compliance_control_name: + type: string compliance_framework: description: Compliance-specific fields type: string + compliance_framework_version: + type: string compliance_impact: items: type: string type: array compliance_result: type: string + compliance_section: + type: string component: allOf: - $ref: '#/definitions/internal_infra_http_handler.FindingComponentInfo' @@ -6151,6 +6519,11 @@ definitions: type: integer end_line: type: integer + epss_percentile: + type: number + epss_score: + description: Threat Intel Enrichment (RFC-004) + type: number estimated_fix_time: type: integer exposure_vector: @@ -6187,13 +6560,19 @@ definitions: type: string impact: type: string + is_in_kev: + type: boolean is_internet_accessible: type: boolean is_network_accessible: type: boolean + is_reachable: + type: boolean is_triaged: description: true if status != "new" type: boolean + kev_due_date: + type: string kind: type: string last_seen_at: @@ -6213,11 +6592,19 @@ definitions: type: object misconfig_actual: type: string + misconfig_cause: + type: string misconfig_expected: type: string misconfig_policy_id: description: Misconfiguration-specific fields type: string + misconfig_policy_name: + type: string + misconfig_resource_name: + type: string + misconfig_resource_path: + type: string misconfig_resource_type: type: string occurrence_count: @@ -6230,13 +6617,22 @@ definitions: additionalProperties: type: string type: object + priority_class: + description: Priority Classification (RFC-004) + type: string + priority_class_override: + type: boolean + priority_class_reason: + type: string rank: type: number + reachable_from_count: + type: integer recommendation: type: string related_locations: items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.FindingLocation' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.FindingLocation' type: array remediation: allOf: @@ -6261,8 +6657,26 @@ definitions: type: string scan_id: type: string + secret_age_in_days: + type: integer + secret_commit_count: + type: integer + secret_entropy: + type: number + secret_expires_at: + type: string + secret_in_history_only: + type: boolean + secret_masked_value: + type: string secret_revoked: type: boolean + secret_rotation_due_at: + type: string + secret_scopes: + items: + type: string + type: array secret_service: type: string secret_type: @@ -6270,6 +6684,8 @@ definitions: type: string secret_valid: type: boolean + secret_verified_at: + type: string severity: type: string sla_deadline: @@ -6282,7 +6698,7 @@ definitions: type: string stacks: items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_vulnerability.StackTrace' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.StackTrace' type: array start_column: type: integer @@ -6302,6 +6718,8 @@ definitions: type: string title: type: string + tool_id: + type: string tool_name: type: string tool_version: @@ -6318,18 +6736,41 @@ definitions: type: array vulnerability_id: type: string + web3_bytecode_offset: + type: integer web3_chain: description: Web3-specific fields type: string + web3_chain_id: + type: integer web3_contract_address: type: string + web3_function_selector: + type: string + web3_function_signature: + type: string web3_swc_id: type: string + web3_tx_hash: + type: string work_item_uris: items: type: string type: array type: object + internal_infra_http_handler.FindingSeverityResponse: + properties: + critical: + type: integer + high: + type: integer + info: + type: integer + low: + type: integer + medium: + type: integer + type: object internal_infra_http_handler.FindingSourceCategoryResponse: properties: code: @@ -6421,6 +6862,21 @@ definitions: total: type: integer type: object + internal_infra_http_handler.FindingTrendPoint: + properties: + critical: + type: integer + date: + type: string + high: + type: integer + info: + type: integer + low: + type: integer + medium: + type: integer + type: object internal_infra_http_handler.FixRegexResponse: properties: count: @@ -6515,6 +6971,8 @@ definitions: type: integer total_count: type: integer + unique_member_count: + type: integer type: object internal_infra_http_handler.GroupMemberResponse: properties: @@ -6531,6 +6989,8 @@ definitions: properties: added_by: type: string + added_by_name: + type: string avatar_url: type: string email: @@ -6544,8 +7004,29 @@ definitions: user_id: type: string type: object + internal_infra_http_handler.GroupOwnershipResponse: + properties: + asset: + $ref: '#/definitions/internal_infra_http_handler.AssetBriefResponse' + asset_id: + type: string + assigned_at: + type: string + assigned_by: + type: string + group_id: + type: string + id: + type: string + ownership_type: + type: string + user_id: + type: string + type: object internal_infra_http_handler.GroupResponse: properties: + asset_count: + type: integer created_at: type: string description: @@ -6561,9 +7042,9 @@ definitions: name: type: string notification_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_group.NotificationConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_group.NotificationConfig' settings: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_group.GroupSettings' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_group.GroupSettings' slug: type: string tenant_id: @@ -6573,6 +7054,8 @@ definitions: type: object internal_infra_http_handler.GroupWithRoleResponse: properties: + asset_count: + type: integer created_at: type: string description: @@ -6590,11 +7073,11 @@ definitions: name: type: string notification_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_group.NotificationConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_group.NotificationConfig' role: type: string settings: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_group.GroupSettings' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_group.GroupSettings' slug: type: string tenant_id: @@ -6619,6 +7102,14 @@ definitions: type: array cpu_percent: type: number + disk_read_mbps: + description: |- + Disk/network throughput in MB/s. Optional — agents that omit them leave + the corresponding load-balancing terms at zero. Accepted here so the + AGENT_LB_DISK_IO_WEIGHT / AGENT_LB_NETWORK_WEIGHT knobs have real inputs. + type: number + disk_write_mbps: + type: number errors: type: integer hostname: @@ -6629,6 +7120,10 @@ definitions: type: string name: type: string + network_rx_mbps: + type: number + network_tx_mbps: + type: number region: type: string scanners: @@ -6647,10 +7142,13 @@ definitions: internal_infra_http_handler.ImportMetadataRequest: properties: batch_id: + maxLength: 100 type: string description: + maxLength: 1000 type: string source_tool: + maxLength: 100 type: string type: object internal_infra_http_handler.ImportOptionsRequest: @@ -6658,6 +7156,10 @@ definitions: auto_classify_severity: type: boolean dedup_strategy: + enum: + - skip + - update + - upsert type: string notify_new_critical: type: boolean @@ -6672,6 +7174,10 @@ definitions: type: integer assets_updated: type: integer + cves_created: + type: integer + cves_updated: + type: integer errors: items: type: string @@ -6859,23 +7365,6 @@ definitions: example: "2024-01-15T10:30:00Z" type: string type: object - internal_infra_http_handler.KeycloakInfoResponse: - properties: - auth_url: - type: string - issuer: - type: string - jwks_url: - type: string - logout_url: - type: string - realm: - type: string - token_url: - type: string - userinfo_url: - type: string - type: object internal_infra_http_handler.LicensingModuleResponse: properties: category: @@ -6884,10 +7373,6 @@ definitions: type: string display_order: type: integer - event_types: - items: - type: string - type: array icon: type: string id: @@ -6897,10 +7382,8 @@ definitions: name: type: string parent_module_id: - description: Parent module ID for sub-modules type: string release_status: - description: released, coming_soon, beta, deprecated type: string slug: type: string @@ -6952,23 +7435,6 @@ definitions: total_pages: type: integer type: object - internal_infra_http_handler.ListResponse-internal_infra_http_handler_BootstrapTokenResponse: - properties: - data: - items: - $ref: '#/definitions/internal_infra_http_handler.BootstrapTokenResponse' - type: array - links: - $ref: '#/definitions/internal_infra_http_handler.PaginationLinks' - page: - type: integer - per_page: - type: integer - total: - type: integer - total_pages: - type: integer - type: object internal_infra_http_handler.ListResponse-internal_infra_http_handler_CommandResponse: properties: data: @@ -7054,41 +7520,7 @@ definitions: total_pages: type: integer type: object - internal_infra_http_handler.ListResponse-internal_infra_http_handler_PlatformAgentResponse: - properties: - data: - items: - $ref: '#/definitions/internal_infra_http_handler.PlatformAgentResponse' - type: array - links: - $ref: '#/definitions/internal_infra_http_handler.PaginationLinks' - page: - type: integer - per_page: - type: integer - total: - type: integer - total_pages: - type: integer - type: object - internal_infra_http_handler.ListResponse-internal_infra_http_handler_PlatformJobResponse: - properties: - data: - items: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobResponse' - type: array - links: - $ref: '#/definitions/internal_infra_http_handler.PaginationLinks' - page: - type: integer - per_page: - type: integer - total: - type: integer - total_pages: - type: integer - type: object - internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanDetailResponse: + internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanDetailResponse: properties: data: items: @@ -7337,6 +7769,16 @@ definitions: refresh_token: description: Also set in httpOnly cookie type: string + suspended_tenants: + description: |- + SuspendedTenants is non-empty when the user has memberships that + are currently suspended. The client uses this to show a clear + "your access to X is suspended" notice instead of bouncing the + user to /onboarding/create-team. Suspended tenants are NOT + accessible — the user cannot pick one and exchange a token. + items: + $ref: '#/definitions/internal_infra_http_handler.TenantInfo' + type: array tenants: items: $ref: '#/definitions/internal_infra_http_handler.TenantInfo' @@ -7346,6 +7788,40 @@ definitions: user: $ref: '#/definitions/internal_infra_http_handler.UserInfo' type: object + internal_infra_http_handler.NotificationEventCategoryResponse: + properties: + category: + example: finding + type: string + label: + example: Finding Events + type: string + type: object + internal_infra_http_handler.NotificationEventTypeResponse: + properties: + category: + example: finding + type: string + default_enabled: + description: |- + DefaultEnabled reports whether a notification integration created with the + defaults receives this event type. enabled_event_types is an opt-in + whitelist, so this is the difference between "delivered" and "silently + dropped" for a channel the operator never customized. + type: boolean + description: + example: Finding missed its SLA remediation deadline + type: string + label: + example: SLA Breached + type: string + required_module: + example: findings + type: string + type: + example: sla_breach + type: string + type: object internal_infra_http_handler.NotificationExtensionResponse: properties: channel_id: @@ -7380,6 +7856,50 @@ definitions: example: 5 type: integer type: object + internal_infra_http_handler.NotificationPreferencesRequest: + properties: + email_digest: + type: string + in_app_enabled: + type: boolean + min_severity: + type: string + muted_types: + items: + type: string + type: array + type: object + internal_infra_http_handler.NotificationResponse: + properties: + actor_id: + type: string + audience: + type: string + audience_id: + type: string + body: + type: string + created_at: + type: string + id: + type: string + is_read: + type: boolean + notification_type: + type: string + resource_id: + type: string + resource_type: + type: string + severity: + type: string + tenant_id: + type: string + title: + type: string + url: + type: string + type: object internal_infra_http_handler.OpenPortResult: properties: banner: @@ -7452,6 +7972,17 @@ definitions: total: type: integer type: object + internal_infra_http_handler.OwnerBriefResponse: + properties: + email: + type: string + id: + type: string + name: + type: string + type: + type: string + type: object internal_infra_http_handler.PaginationLinks: properties: first: @@ -7553,287 +8084,91 @@ definitions: version: type: integer type: object - internal_infra_http_handler.PlanResponse: + internal_infra_http_handler.PortCountResponse: + properties: + count: + type: integer + port: + type: integer + type: object + internal_infra_http_handler.PreferencesDTO: properties: - badge: + language: type: string - created_at: + notifications: + type: boolean + theme: type: string - currency: + type: object + internal_infra_http_handler.PreferencesResponse: + properties: + email_digest: type: string - description: + in_app_enabled: + type: boolean + min_severity: type: string - display_order: - type: integer - features: + muted_types: items: type: string type: array - id: - type: string - is_popular: - type: boolean - is_public: - type: boolean - max_assets: - type: integer - max_users: - type: integer - name: - type: string - price_monthly: - type: number - price_yearly: - type: number - slug: - type: string - support_level: - type: string - trial_days: - type: integer updated_at: type: string type: object - internal_infra_http_handler.PlatformAgentResponse: + internal_infra_http_handler.ProvidersResponse: properties: - available_slots: - type: integer - capabilities: + providers: items: - type: string + $ref: '#/definitions/github_com_openctemio_api_internal_app.ProviderInfo' type: array - cpu_percent: - type: number - created_at: - type: string - current_jobs: + type: object + internal_infra_http_handler.QualityGateBreachResponse: + properties: + actual: type: integer - description: - type: string - disk_read_mbps: - type: number - disk_write_mbps: - type: number - error_count: + limit: type: integer - health: - description: online, offline, unknown - type: string - hostname: - type: string - id: - type: string - ip_address: + metric: type: string - labels: - additionalProperties: {} - type: object - last_seen_at: - description: Statistics + type: object + internal_infra_http_handler.QualityGateRequest: + properties: + baseline_branch: type: string - load_factor: - type: number - load_score: - description: Weighted score for load balancing (lower is better) - type: number - max_concurrent_jobs: - description: Load balancing + enabled: + type: boolean + fail_on_critical: + type: boolean + fail_on_high: + type: boolean + max_critical: type: integer - memory_percent: - type: number - name: - type: string - network_rx_mbps: - type: number - network_tx_mbps: - type: number - region: - type: string - status: - description: active, disabled, revoked - type: string - tier: - description: Tier (v3.3) - type: string - tier_priority: - description: 0, 50, 100 + max_high: type: integer - tools: - items: - type: string - type: array - total_findings: + max_medium: type: integer - total_scans: + max_total: type: integer - type: - type: string - updated_at: - type: string - version: - type: string + new_findings_only: + type: boolean type: object - internal_infra_http_handler.PlatformAgentStatsResponse: + internal_infra_http_handler.QualityGateResponse: properties: - available_capabilities: - items: - type: string - type: array - available_regions: - items: - type: string - type: array - available_slots: + baseline_branch: + type: string + enabled: + type: boolean + fail_on_critical: + type: boolean + fail_on_high: + type: boolean + max_critical: type: integer - available_tools: - items: - type: string - type: array - current_load: + max_high: type: integer - load_percent: - type: number - offline_agents: + max_medium: type: integer - online_agents: - type: integer - tier_stats: - additionalProperties: - $ref: '#/definitions/internal_infra_http_handler.TierStatsResponse' - description: v3.3 - type: object - total_agents: - type: integer - total_capacity: - type: integer - type: object - internal_infra_http_handler.PlatformJobAgentResponse: - properties: - id: - type: string - name: - type: string - region: - type: string - type: object - internal_infra_http_handler.PlatformJobQueueResponse: - properties: - estimated_wait_seconds: - type: integer - position: - type: integer - type: object - internal_infra_http_handler.PlatformJobResponse: - properties: - acknowledged_at: - type: string - agent_id: - type: string - agent_name: - type: string - completed_at: - type: string - created_at: - type: string - error_message: - type: string - id: - type: string - payload: - items: - type: integer - type: array - priority: - type: string - queue_position: - type: integer - queue_priority: - type: integer - queued_at: - type: string - result: - items: - type: integer - type: array - started_at: - type: string - status: - type: string - tenant_id: - type: string - type: - type: string - type: object - internal_infra_http_handler.PortCountResponse: - properties: - count: - type: integer - port: - type: integer - type: object - internal_infra_http_handler.PreferencesDTO: - properties: - language: - type: string - notifications: - type: boolean - theme: - type: string - type: object - internal_infra_http_handler.ProvidersResponse: - properties: - providers: - items: - $ref: '#/definitions/github_com_openctemio_api_internal_app.ProviderInfo' - type: array - type: object - internal_infra_http_handler.QualityGateBreachResponse: - properties: - actual: - type: integer - limit: - type: integer - metric: - type: string - type: object - internal_infra_http_handler.QualityGateRequest: - properties: - baseline_branch: - type: string - enabled: - type: boolean - fail_on_critical: - type: boolean - fail_on_high: - type: boolean - max_critical: - type: integer - max_high: - type: integer - max_medium: - type: integer - max_total: - type: integer - new_findings_only: - type: boolean - type: object - internal_infra_http_handler.QualityGateResponse: - properties: - baseline_branch: - type: string - enabled: - type: boolean - fail_on_critical: - type: boolean - fail_on_high: - type: boolean - max_critical: - type: integer - max_high: - type: integer - max_medium: - type: integer - max_total: + max_total: type: integer new_findings_only: type: boolean @@ -7851,17 +8186,6 @@ definitions: reason: type: string type: object - internal_infra_http_handler.QueueStatsResponse: - properties: - total_completed: - type: integer - total_failed: - type: integer - total_processing: - type: integer - total_queued: - type: integer - type: object internal_infra_http_handler.ReadyResponse: properties: checks: @@ -7950,63 +8274,20 @@ definitions: token_type: type: string type: object - internal_infra_http_handler.RegisterAgentRequest: - properties: - bootstrap_token: - type: string - capabilities: - items: - type: string - maxItems: 20 - minItems: 1 - type: array - hostname: - maxLength: 255 - type: string - labels: - additionalProperties: - type: string - type: object - max_concurrent: - maximum: 50 - minimum: 1 - type: integer - name: - maxLength: 255 - minLength: 1 - type: string - region: - maxLength: 50 - type: string - tools: - items: - type: string - maxItems: 20 - minItems: 1 - type: array - version: - maxLength: 50 - type: string - required: - - bootstrap_token - - capabilities - - max_concurrent - - name - - region - - tools - type: object - internal_infra_http_handler.RegisterAgentResponse: - properties: - agent: - $ref: '#/definitions/internal_infra_http_handler.PlatformAgentResponse' - api_key: - type: string - type: object internal_infra_http_handler.RegisterRequest: properties: email: maxLength: 255 type: string + invitation_token: + description: |- + InvitationToken is optional: when present, the register flow + resolves the target tenant from the invitation and uses that + tenant's email-verification rule. Without this, registrations via + invitation links would fall back to the platform default and + silently ignore an admin's per-tenant "never" setting. + maxLength: 200 + type: string name: maxLength: 255 type: string @@ -8072,6 +8353,13 @@ definitions: required: - asset_ids type: object + internal_infra_http_handler.RenewKeyResponse: + properties: + api_key: + type: string + expires_at: + type: string + type: object internal_infra_http_handler.RepositoryExtensionResponse: properties: asset_id: @@ -8157,12 +8445,6 @@ definitions: - new_password - token type: object - internal_infra_http_handler.RevokeBootstrapTokenRequest: - properties: - reason: - maxLength: 500 - type: string - type: object internal_infra_http_handler.RunResponse: properties: asset_id: @@ -8184,7 +8466,7 @@ definitions: pipeline_id: type: string quality_gate_result: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_scanprofile.QualityGateResult' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_scanprofile.QualityGateResult' scan_id: type: string scan_profile_id: @@ -8342,6 +8624,8 @@ definitions: type: object internal_infra_http_handler.ScanDetailResponse: properties: + agent_preference: + type: string asset_group_id: description: Primary asset group (legacy) type: string @@ -8368,12 +8652,18 @@ definitions: type: string last_run_status: type: string + max_retries: + type: integer name: type: string next_run_at: type: string pipeline_id: type: string + profile_id: + type: string + retry_backoff_seconds: + type: integer run_on_tenant_runner: type: boolean scan_type: @@ -8410,6 +8700,8 @@ definitions: type: integer tenant_id: type: string + timeout_seconds: + type: integer total_runs: type: integer updated_at: @@ -8626,6 +8918,21 @@ definitions: version: type: string type: object + internal_infra_http_handler.ScopeBulkOperationResponse: + properties: + affected_count: + type: integer + errors: + additionalProperties: + type: string + type: object + failed_ids: + items: + type: string + type: array + success: + type: boolean + type: object internal_infra_http_handler.ScopeExclusionResponse: properties: approved_at: @@ -8653,6 +8960,38 @@ definitions: updated_at: type: string type: object + internal_infra_http_handler.ScopeMatchResponse: + properties: + excluded: + type: boolean + in_scope: + type: boolean + matched_exclusion_ids: + items: + type: string + type: array + matched_target_ids: + items: + type: string + type: array + type: object + internal_infra_http_handler.ScopeStatsResponse: + properties: + active_exclusions: + type: integer + active_targets: + type: integer + coverage: + type: number + enabled_schedules: + type: integer + total_exclusions: + type: integer + total_schedules: + type: integer + total_targets: + type: integer + type: object internal_infra_http_handler.ScopeTargetResponse: properties: created_at: @@ -8680,33 +9019,6 @@ definitions: updated_at: type: string type: object - internal_infra_http_handler.SendNotificationRequest: - properties: - body: - maxLength: 4000 - type: string - fields: - additionalProperties: - type: string - type: object - severity: - enum: - - critical - - high - - medium - - low - type: string - title: - maxLength: 255 - minLength: 1 - type: string - url: - type: string - required: - - body - - severity - - title - type: object internal_infra_http_handler.SessionsResponse: properties: sessions: @@ -8731,6 +9043,15 @@ definitions: reason: type: string type: object + internal_infra_http_handler.SocialProviders: + properties: + github: + type: boolean + google: + type: boolean + microsoft: + type: boolean + type: object internal_infra_http_handler.StateChangeResponse: properties: asset_id: @@ -8809,77 +9130,7 @@ definitions: source: type: string type: object - internal_infra_http_handler.SubmitJobRequest: - properties: - capabilities: - items: - type: string - maxItems: 20 - type: array - expires_in_min: - description: Max 24 hours - maximum: 1440 - minimum: 1 - type: integer - payload: - additionalProperties: true - type: object - preferred_region: - maxLength: 50 - type: string - priority: - enum: - - low - - normal - - high - - critical - type: string - tool: - maxLength: 50 - type: string - type: - type: string - required: - - payload - - type - type: object - internal_infra_http_handler.SubmitJobResponse: - properties: - agent: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobAgentResponse' - auth_token: - type: string - job: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobResponse' - queue: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobQueueResponse' - status: - description: assigned, queued, rejected - type: string - type: object - internal_infra_http_handler.SubscriptionResponse: - properties: - billing_cycle: - type: string - canceled_at: - type: string - expires_at: - type: string - limits: - additionalProperties: {} - type: object - plan: - $ref: '#/definitions/internal_infra_http_handler.PlanResponse' - plan_id: - type: string - started_at: - type: string - status: - type: string - tenant_id: - type: string - type: object - internal_infra_http_handler.SyncResponse: + internal_infra_http_handler.SyncResponse: properties: message: type: string @@ -8922,9 +9173,9 @@ definitions: enabled: type: boolean git_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.GitSourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.GitSourceConfig' http_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.HTTPSourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.HTTPSourceConfig' id: type: string last_sync_at: @@ -8940,7 +9191,7 @@ definitions: name: type: string s3_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.S3SourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.S3SourceConfig' source_type: type: string template_type: @@ -8972,40 +9223,6 @@ definitions: usage: $ref: '#/definitions/internal_infra_http_handler.TemplateUsageData' type: object - internal_infra_http_handler.NotificationEventCategoryResponse: - properties: - category: - example: finding - type: string - label: - example: Finding Events - type: string - type: object - internal_infra_http_handler.NotificationEventTypeResponse: - properties: - category: - example: finding - type: string - default_enabled: - description: |- - DefaultEnabled reports whether a notification integration created with the - defaults receives this event type. enabled_event_types is an opt-in - whitelist, so this is the difference between "delivered" and "silently - dropped" for a channel the operator never customized. - type: boolean - description: - example: Finding missed its SLA remediation deadline - type: string - label: - example: SLA Breached - type: string - required_module: - example: findings - type: string - type: - example: sla_breach - type: string - type: object internal_infra_http_handler.TenantEventTypesResponse: properties: categories: @@ -9065,6 +9282,12 @@ definitions: type: string type: array coming_soon_module_ids: + description: |- + EventTypes was declared here but never assigned by buildModulesResponse, + so it never appeared on the wire. The notification event-type catalog + is served by GET /me/event-types, which has the label/description/category + metadata a caller actually needs. Removed rather than populated: a + bare []string here would be a second, thinner copy of that catalog. items: type: string type: array @@ -9081,7 +9304,6 @@ definitions: items: $ref: '#/definitions/internal_infra_http_handler.LicensingModuleResponse' type: array - description: parent_module_id -> sub-modules type: object type: object internal_infra_http_handler.TenantToolConfigRequest: @@ -9197,25 +9419,6 @@ definitions: example: octocat type: string type: object - internal_infra_http_handler.TierStatsResponse: - properties: - available_slots: - type: integer - current_load: - type: integer - offline_agents: - type: integer - online_agents: - type: integer - queued_jobs: - type: integer - tier: - type: string - total_agents: - type: integer - total_capacity: - type: integer - type: object internal_infra_http_handler.ToolConfigRequest: properties: custom_template_ids: @@ -9383,6 +9586,11 @@ definitions: additionalProperties: {} type: object type: object + internal_infra_http_handler.UnreadCountResponse: + properties: + count: + type: integer + type: object internal_infra_http_handler.UpdateAgentRequest: properties: capabilities: @@ -9434,6 +9642,11 @@ definitions: maxLength: 255 type: string owner_email: + description: |- + NOTE: no `email` rule here — `omitempty` does not skip a non-nil *string + pointing at "" (it is only nil-aware), so keeping `email` rejected the + legitimate "clear the owner email" case with a 422. Format is validated in + the service layer, but only for non-empty values (empty = clear). maxLength: 255 type: string tags: @@ -9467,6 +9680,12 @@ definitions: maxLength: 255 minLength: 1 type: string + owner_ref: + maxLength: 500 + type: string + properties: + additionalProperties: {} + type: object scope: type: string tags: @@ -9503,6 +9722,11 @@ definitions: - inactive - filtered type: string + technologies: + items: + type: string + maxItems: 50 + type: array tls_enabled: type: boolean tls_version: @@ -9620,8 +9844,10 @@ definitions: maxLength: 100 minLength: 2 type: string + notification_config: + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_group.NotificationConfig' settings: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_group.GroupSettings' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_group.GroupSettings' slug: maxLength: 100 minLength: 2 @@ -9632,6 +9858,9 @@ definitions: properties: base_url: type: string + config: + additionalProperties: {} + type: object credentials: maxLength: 5000 type: string @@ -9647,22 +9876,6 @@ definitions: maxLength: 255 type: string type: object - internal_infra_http_handler.UpdateJobStatusRequest: - properties: - error_message: - type: string - result: - additionalProperties: true - type: object - status: - enum: - - running - - completed - - failed - type: string - required: - - status - type: object internal_infra_http_handler.UpdateNotificationIntegrationRequest: properties: channel_id: @@ -9902,15 +10115,31 @@ definitions: type: object internal_infra_http_handler.UpdateScanRequest: properties: + agent_preference: + enum: + - auto + - tenant + - platform + type: string description: maxLength: 1000 type: string + max_retries: + maximum: 10 + minimum: 0 + type: integer name: maxLength: 200 minLength: 1 type: string pipeline_id: type: string + profile_id: + type: string + retry_backoff_seconds: + maximum: 86400 + minimum: 10 + type: integer run_on_tenant_runner: type: boolean scanner_config: @@ -9941,10 +10170,57 @@ definitions: type: array targets_per_job: type: integer + timeout_seconds: + maximum: 86400 + minimum: 30 + type: integer timezone: maxLength: 50 type: string type: object + internal_infra_http_handler.UpdateScanScheduleRequest: + properties: + cron_expression: + maxLength: 100 + type: string + description: + maxLength: 1000 + type: string + interval_hours: + maximum: 8760 + minimum: 0 + type: integer + name: + maxLength: 200 + minLength: 1 + type: string + notification_channels: + items: + type: string + maxItems: 10 + type: array + notify_on_completion: + type: boolean + notify_on_findings: + type: boolean + scanner_configs: + additionalProperties: true + type: object + schedule_type: + type: string + target_ids: + items: + type: string + maxItems: 100 + type: array + target_scope: + type: string + target_tags: + items: + type: string + maxItems: 20 + type: array + type: object internal_infra_http_handler.UpdateScanSessionRequest: properties: error_message: @@ -10032,15 +10308,15 @@ definitions: enabled: type: boolean git_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.GitSourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.GitSourceConfig' http_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.HTTPSourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.HTTPSourceConfig' name: maxLength: 255 minLength: 1 type: string s3_config: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_templatesource.S3SourceConfig' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_templatesource.S3SourceConfig' type: object internal_infra_http_handler.UpdateToolRequest: properties: @@ -10276,6 +10552,21 @@ definitions: updated_at: type: string type: object + internal_infra_http_handler.WSTokenResponse: + properties: + expires_in: + description: Seconds until expiration + type: integer + token: + type: string + type: object + internal_infra_http_handler.remediationGroupsResponse: + properties: + groups: + items: + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_remediation.Group' + type: array + type: object externalDocs: description: OpenAPI url: https://swagger.io/resources/open-api/ @@ -10419,29 +10710,16 @@ paths: summary: Get audit log statistics tags: - Admin Audit Logs - /admin/bootstrap-tokens: + /agent/commands: get: consumes: - application/json - description: Get a paginated list of bootstrap tokens (admin only) + description: Agent polls for pending commands to execute parameters: - - description: Filter by status (active, revoked, expired, exhausted) - in: query - name: status - type: string - - description: Search by description - in: query - name: search - type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page + - default: 10 + description: Max commands to return in: query - name: per_page + name: limit type: integer produces: - application/json @@ -10449,43 +10727,40 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_BootstrapTokenResponse' + items: + $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + type: array "401": description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: List bootstrap tokens + - ApiKeyAuth: [] + summary: Poll commands tags: - - Bootstrap Tokens + - Agent + /agent/commands/{id}/acknowledge: post: consumes: - application/json - description: Create a new bootstrap token for agent self-registration (admin - only) + description: Agent acknowledges receipt of a command parameters: - - description: Token data - in: body - name: body + - description: Command ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CreateBootstrapTokenRequest' + type: string produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CreateBootstrapTokenResponse' + $ref: '#/definitions/internal_infra_http_handler.CommandResponse' "400": description: Bad Request schema: @@ -10494,139 +10769,113 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Create bootstrap token + - ApiKeyAuth: [] + summary: Acknowledge command tags: - - Bootstrap Tokens - /admin/bootstrap-tokens/{id}/revoke: + - Agent + /agent/commands/{id}/complete: post: consumes: - application/json - description: Revoke a bootstrap token (admin only) + description: Agent reports successful command completion with optional result parameters: - - description: Token ID + - description: Command ID in: path name: id required: true type: string - - description: Revoke reason + - description: Completion result in: body name: body schema: - $ref: '#/definitions/internal_infra_http_handler.RevokeBootstrapTokenRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateCommandStatusRequest' produces: - application/json responses: - "204": - description: No Content - "401": - description: Unauthorized + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Revoke bootstrap token + - ApiKeyAuth: [] + summary: Complete command tags: - - Bootstrap Tokens - /admin/platform-agents: - get: + - Agent + /agent/commands/{id}/fail: + post: consumes: - application/json - description: Get a paginated list of platform agents (admin only) + description: Agent reports command execution failure with error message parameters: - - description: Filter by health (online, offline, unknown) - in: query - name: health - type: string - - description: Filter by region - in: query - name: region - type: string - - description: Filter by tier (shared, dedicated, premium) - in: query - name: tier - type: string - - description: Filter by capabilities (comma-separated) - in: query - name: capabilities - type: string - - description: Filter by tools (comma-separated) - in: query - name: tools + - description: Command ID + in: path + name: id + required: true type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer + - description: Error details + in: body + name: body + schema: + $ref: '#/definitions/internal_infra_http_handler.UpdateCommandStatusRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_PlatformAgentResponse' - "401": - description: Unauthorized + $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: List platform agents + - ApiKeyAuth: [] + summary: Fail command tags: - - Platform Agents + - Agent + /agent/commands/{id}/start: post: consumes: - application/json - description: Create a new platform agent (admin only) + description: Agent reports that command execution has started parameters: - - description: Agent data - in: body - name: body + - description: Command ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CreatePlatformAgentRequest' + type: string produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CreatePlatformAgentResponse' + $ref: '#/definitions/internal_infra_http_handler.CommandResponse' "400": description: Bad Request schema: @@ -10635,126 +10884,93 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Conflict - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Create platform agent + - ApiKeyAuth: [] + summary: Start command tags: - - Platform Agents - /admin/platform-agents/{id}: - delete: + - Agent + /agent/heartbeat: + post: consumes: - application/json - description: Delete a platform agent (admin only) + description: Send a heartbeat to indicate agent is alive parameters: - - description: Agent ID - in: path - name: id - required: true - type: string + - description: Heartbeat data + in: body + name: request + schema: + $ref: '#/definitions/internal_infra_http_handler.HeartbeatRequest' produces: - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + additionalProperties: true + type: object "401": description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Delete platform agent + - ApiKeyAuth: [] + summary: Agent heartbeat tags: - - Platform Agents - get: + - Agent + /agent/ingest/baseline-diff: + post: consumes: - application/json - description: Get a single platform agent by ID (admin only) + description: |- + Given the current scan's fingerprints + a PR base/target branch, + returns which are NEW (not already open on the base branch) so a + PR gate / inline comments focus only on findings the PR introduces. parameters: - - description: Agent ID - in: path - name: id + - description: Repository, base branch, fingerprints + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.BaselineDiffRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.PlatformAgentResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get platform agent + $ref: '#/definitions/github_com_openctemio_api_internal_app_ingest.BaselineDiffOutput' + summary: New-vs-base-branch findings tags: - - Platform Agents - /admin/platform-agents/{id}/disable: + - Agent + /agent/ingest/check: post: consumes: - application/json - description: Disable a platform agent (admin only) + description: Check if fingerprints already exist for deduplication parameters: - - description: Agent ID - in: path - name: id + - description: Fingerprints to check + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.CheckFingerprintsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.PlatformAgentResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden + $ref: '#/definitions/internal_infra_http_handler.CheckFingerprintsResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -10762,38 +10978,36 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Disable platform agent + - ApiKeyAuth: [] + summary: Check fingerprints tags: - - Platform Agents - /admin/platform-agents/{id}/enable: + - Agent + /agent/ingest/chunk: post: consumes: - application/json - description: Enable a disabled platform agent (admin only) + description: Ingest a single chunk of a large CTIS report. Used for reports + that exceed single upload limits. parameters: - - description: Agent ID - in: path - name: id + - description: Chunk data + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.ChunkIngestRequest' produces: - application/json responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.PlatformAgentResponse' - "401": - description: Unauthorized + "201": + description: Created schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden + $ref: '#/definitions/internal_infra_http_handler.ChunkIngestResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -10801,28 +11015,36 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Enable platform agent + - ApiKeyAuth: [] + summary: Ingest CTIS report chunk tags: - - Platform Agents - /admin/platform-agents/stats: - get: + - Agent + /agent/ingest/ctis: + post: consumes: - application/json - description: Get aggregate statistics for platform agents (admin only) + description: Ingest a full CTIS (CTEM Ingest Schema) report containing assets + and findings + parameters: + - description: CTIS report + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.CTISIngestRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.PlatformAgentStatsResponse' - "401": - description: Unauthorized + $ref: '#/definitions/internal_infra_http_handler.IngestResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -10830,119 +11052,29 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Get platform agent statistics + - ApiKeyAuth: [] + summary: Ingest CTIS report tags: - - Platform Agents - /admin/platform-jobs: - get: - consumes: - - application/json - description: List platform jobs across all tenants (admin only) - parameters: - - description: Filter by status - in: query - name: status - type: string - - description: Filter by agent ID - in: query - name: agent_id - type: string - - description: Filter by tenant ID - in: query - name: tenant_id - type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_PlatformJobResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: List all platform jobs (admin) - tags: - - Platform Jobs Admin - /admin/platform-jobs/{id}: - get: - consumes: - - application/json - description: Get platform job details (admin only, no tenant restriction) - parameters: - - description: Job ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get job details (admin) - tags: - - Platform Jobs Admin - /admin/platform-jobs/{id}/cancel: + - Agent + /agent/ingest/recon: post: consumes: - application/json - description: Cancel a platform job (admin only) + description: Ingest reconnaissance scan results (subdomains, DNS, ports, etc.) parameters: - - description: Job ID - in: path - name: id + - description: Recon results + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.ReconIngestRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobResponse' + $ref: '#/definitions/internal_infra_http_handler.IngestResponse' "400": description: Bad Request schema: @@ -10951,41 +11083,34 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Cancel job (admin) + - ApiKeyAuth: [] + summary: Ingest recon results tags: - - Platform Jobs Admin - /admin/platform-jobs/{id}/retry: + - Agent + /agent/ingest/sarif: post: consumes: - application/json - description: Retry a failed platform job (admin only) + description: Ingest scan results in SARIF 2.1.0 format parameters: - - description: Job ID - in: path - name: id + - description: SARIF data + in: body + name: request required: true - type: string + schema: + type: object produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobResponse' + $ref: '#/definitions/internal_infra_http_handler.IngestResponse' "400": description: Bad Request schema: @@ -10994,35 +11119,29 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Retry job (admin) + - ApiKeyAuth: [] + summary: Ingest SARIF results tags: - - Platform Jobs Admin - /admin/platform-jobs/stats: - get: + - Agent + /agent/renew: + post: consumes: - application/json - description: Get platform job queue statistics (admin only) + description: Rotate the calling agent's own API key. Authenticated by the current + key; returns a fresh key shown once. The building block for auto-rotating + credentials (kubelet-style). produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.QueueStatsResponse' + $ref: '#/definitions/internal_infra_http_handler.RenewKeyResponse' "401": description: Unauthorized schema: @@ -11036,61 +11155,29 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - BearerAuth: [] - summary: Get queue statistics - tags: - - Platform Jobs Admin - /agent/commands: - get: - consumes: - - application/json - description: Agent polls for pending commands to execute - parameters: - - default: 10 - description: Max commands to return - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/internal_infra_http_handler.CommandResponse' - type: array - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - ApiKeyAuth: [] - summary: Poll commands + summary: Renew agent API key (self-service) tags: - Agent - /agent/commands/{id}/acknowledge: + /agent/scans: post: consumes: - application/json - description: Agent acknowledges receipt of a command + description: Agent registers a new scan session before starting a scan parameters: - - description: Command ID - in: path - name: id + - description: Scan registration data + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.RegisterScanRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + $ref: '#/definitions/internal_infra_http_handler.RegisterScanResponse' "400": description: Bad Request schema: @@ -11099,38 +11186,33 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "500": + description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - ApiKeyAuth: [] - summary: Acknowledge command + summary: Register scan session tags: - Agent - /agent/commands/{id}/complete: - post: + /agent/scans/{id}: + get: consumes: - application/json - description: Agent reports successful command completion with optional result + description: Agent retrieves scan session details parameters: - - description: Command ID + - description: Scan session ID in: path name: id required: true type: string - - description: Completion result - in: body - name: body - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateCommandStatusRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanSessionResponse' "400": description: Bad Request schema: @@ -11143,34 +11225,40 @@ paths: description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - ApiKeyAuth: [] - summary: Complete command + summary: Get scan session (agent) tags: - Agent - /agent/commands/{id}/fail: - post: + patch: consumes: - application/json - description: Agent reports command execution failure with error message + description: Agent updates scan status after completion parameters: - - description: Command ID + - description: Scan session ID in: path name: id required: true type: string - - description: Error details + - description: Update data in: body - name: body + name: request + required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateCommandStatusRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateScanSessionRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + additionalProperties: + type: string + type: object "400": description: Bad Request schema: @@ -11179,98 +11267,70 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "500": + description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - ApiKeyAuth: [] - summary: Fail command + summary: Update scan session tags: - Agent - /agent/commands/{id}/start: - post: + /agents: + get: consumes: - application/json - description: Agent reports that command execution has started + description: Get a paginated list of agents for the current tenant parameters: - - description: Command ID - in: path - name: id - required: true + - description: Filter by type (runner, worker, collector, sensor) + in: query + name: type type: string + - description: Filter by admin-controlled status (active, disabled, revoked) + in: query + name: status + type: string + - description: Filter by automatic health (unknown, online, offline, error) + in: query + name: health + type: string + - description: Filter by execution mode (standalone, daemon) + in: query + name: execution_mode + type: string + - description: Filter by capabilities (comma-separated) + in: query + name: capabilities + type: string + - description: Filter by tools (comma-separated) + in: query + name: tools + type: string + - description: Filter by agents with available capacity + in: query + name: has_capacity + type: boolean + - description: Search by name or description + in: query + name: search + type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CommandResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - ApiKeyAuth: [] - summary: Start command - tags: - - Agent - /agent/heartbeat: - post: - consumes: - - application/json - description: Send a heartbeat to indicate agent is alive - parameters: - - description: Heartbeat data - in: body - name: request - schema: - $ref: '#/definitions/internal_infra_http_handler.HeartbeatRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - ApiKeyAuth: [] - summary: Agent heartbeat - tags: - - Agent - /agent/ingest/chunk: - post: - consumes: - - application/json - description: Ingest a single chunk of a large CTIS report. Used for reports that - exceed single upload limits. - parameters: - - description: Chunk data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.ChunkIngestRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/internal_infra_http_handler.ChunkIngestResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_AgentResponse' "400": description: Bad Request schema: @@ -11284,35 +11344,34 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Ingest CTIS report chunk + - BearerAuth: [] + summary: List agents tags: - - Agent - /agent/ingest/recon: + - Agents post: consumes: - application/json - description: Ingest reconnaissance scan results (subdomains, DNS, ports, etc.) + description: Create a new agent and receive its API key parameters: - - description: Recon results + - description: Agent data in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.ReconIngestRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateAgentRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.IngestResponse' + $ref: '#/definitions/internal_infra_http_handler.CreateAgentResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "409": + description: Conflict schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -11320,36 +11379,32 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Ingest recon results + - BearerAuth: [] + summary: Create agent tags: - - Agent - /agent/ingest/ctis: - post: + - Agents + /agents/{id}: + delete: consumes: - application/json - description: Ingest a full CTIS (CTEM Ingest Schema) report containing - assets and findings + description: Delete an agent and revoke its API key parameters: - - description: CTIS report - in: body - name: request + - description: Agent ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.EISIngestRequest' + type: string produces: - application/json responses: - "201": - description: Created - schema: - $ref: '#/definitions/internal_infra_http_handler.IngestResponse' + "204": + description: No Content "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -11357,35 +11412,33 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Ingest CTIS report + - BearerAuth: [] + summary: Delete agent tags: - - Agent - /agent/ingest/sarif: - post: + - Agents + get: consumes: - application/json - description: Ingest scan results in SARIF 2.1.0 format + description: Get a single agent by ID parameters: - - description: SARIF data - in: body - name: request + - description: Agent ID + in: path + name: id required: true - schema: - type: object + type: string produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.IngestResponse' + $ref: '#/definitions/internal_infra_http_handler.AgentResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -11393,35 +11446,39 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Ingest SARIF results + - BearerAuth: [] + summary: Get agent tags: - - Agent - /agent/scans: - post: + - Agents + put: consumes: - application/json - description: Agent registers a new scan session before starting a scan + description: Update an existing agent parameters: - - description: Scan registration data + - description: Agent ID + in: path + name: id + required: true + type: string + - description: Update data in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.RegisterScanRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateAgentRequest' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.RegisterScanResponse' + $ref: '#/definitions/internal_infra_http_handler.AgentResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -11429,17 +11486,17 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Register scan session + - BearerAuth: [] + summary: Update agent tags: - - Agent - /agent/scans/{id}: - get: + - Agents + /agents/{id}/activate: + post: consumes: - application/json - description: Agent retrieves scan session details + description: Activate an agent (admin action). Allows the agent to authenticate. parameters: - - description: Scan session ID + - description: Agent ID in: path name: id required: true @@ -11450,13 +11507,13 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanSessionResponse' + $ref: '#/definitions/internal_infra_http_handler.AgentResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "403": + description: Forbidden schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": @@ -11468,41 +11525,77 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Get scan session (agent) + - BearerAuth: [] + summary: Activate agent tags: - - Agent - patch: - consumes: - - application/json - description: Agent updates scan status after completion + - Agents + /agents/{id}/config-templates: + get: + description: Returns rendered config templates for an agent in multiple formats parameters: - - description: Scan session ID + - description: Agent ID in: path name: id required: true type: string - - description: Update data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateScanSessionRequest' + - description: Optional API key to embed in templates (only available right + after creation/regeneration). MUST be sent as header, not query parameter. + in: header + name: X-Agent-API-Key + type: string produces: - application/json responses: "200": description: OK schema: - additionalProperties: - type: string - type: object - "400": - description: Bad Request + $ref: '#/definitions/internal_infra_http_handler.AgentConfigTemplatesResponse' + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "503": + description: Template service not configured + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Get agent configuration templates + tags: + - Agents + /agents/{id}/deactivate: + post: + consumes: + - application/json + description: Disable an agent (admin action). Prevents the agent from authenticating. + parameters: + - description: Agent ID + in: path + name: id + required: true + type: string + - description: Disable reason + in: body + name: body + schema: + $ref: '#/definitions/internal_infra_http_handler.AgentDisableRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.AgentResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -11510,47 +11603,177 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Update scan session + - BearerAuth: [] + summary: Disable agent tags: - - Agent - /agents: + - Agents + /agents/{id}/regenerate-key: + post: + consumes: + - application/json + description: Regenerate the API key for an agent. The old key will be invalidated. + parameters: + - description: Agent ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.AgentRegenerateAPIKeyResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Regenerate API key + tags: + - Agents + /agents/{id}/revoke: + post: + consumes: + - application/json + description: Permanently revoke an agent's access (admin action). Cannot be + undone. + parameters: + - description: Agent ID + in: path + name: id + required: true + type: string + - description: Revoke reason + in: body + name: body + schema: + $ref: '#/definitions/internal_infra_http_handler.AgentRevokeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.AgentResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Revoke agent + tags: + - Agents + /agents/available-capabilities: + get: + description: Returns all unique capability names from all agents accessible + to the tenant + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.AvailableCapabilitiesResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Get available capabilities + tags: + - agents + /agents/stats: + get: + description: Returns aggregated stats for the tenant's agents (status, health, + type, mode breakdowns) + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.AgentStatsResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Get tenant agent statistics + tags: + - Agents + /asset-groups: get: consumes: - application/json - description: Get a paginated list of agents for the current tenant + description: Get a paginated list of asset groups for the current tenant parameters: - - description: Filter by type (runner, worker, collector, sensor) + - description: Search by name in: query - name: type + name: search type: string - - description: Filter by admin-controlled status (active, disabled, revoked) + - description: Filter by environments (comma-separated) in: query - name: status + name: environments type: string - - description: Filter by automatic health (unknown, online, offline, error) + - description: Filter by criticalities (comma-separated) in: query - name: health + name: criticalities type: string - - description: Filter by execution mode (standalone, daemon) + - description: Filter by business unit in: query - name: execution_mode + name: business_unit type: string - - description: Filter by capabilities (comma-separated) + - description: Filter by owner in: query - name: capabilities + name: owner type: string - - description: Filter by tools (comma-separated) + - description: Filter by tags (comma-separated) in: query - name: tools + name: tags type: string - - description: Filter by agents with available capacity + - description: Filter groups with findings in: query - name: has_capacity + name: has_findings type: boolean - - description: Search by name or description + - description: Minimum risk score in: query - name: search + name: min_risk_score + type: integer + - description: Maximum risk score + in: query + name: max_risk_score + type: integer + - description: Sort field (name, created_at, risk_score) + in: query + name: sort type: string - default: 1 description: Page number @@ -11568,7 +11791,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_AgentResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_AssetGroupResponse' "400": description: Bad Request schema: @@ -11583,27 +11806,27 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List agents + summary: List asset groups tags: - - Agents + - Asset Groups post: consumes: - application/json - description: Create a new agent and receive its API key + description: Create a new asset group parameters: - - description: Agent data + - description: Asset group data in: body name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateAgentRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateAssetGroupRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.CreateAgentResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' "400": description: Bad Request schema: @@ -11618,16 +11841,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create agent + summary: Create asset group tags: - - Agents - /agents/{id}: + - Asset Groups + /asset-groups/{id}: delete: consumes: - application/json - description: Delete an agent and revoke its API key + description: Delete an asset group parameters: - - description: Agent ID + - description: Asset Group ID in: path name: id required: true @@ -11651,15 +11874,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete agent + summary: Delete asset group tags: - - Agents + - Asset Groups get: consumes: - application/json - description: Get a single agent by ID + description: Get a single asset group by ID parameters: - - description: Agent ID + - description: Asset Group ID in: path name: id required: true @@ -11670,7 +11893,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AgentResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' "400": description: Bad Request schema: @@ -11685,15 +11908,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get agent + summary: Get asset group tags: - - Agents + - Asset Groups put: consumes: - application/json - description: Update an existing agent + description: Update an existing asset group parameters: - - description: Agent ID + - description: Asset Group ID in: path name: id required: true @@ -11703,14 +11926,14 @@ paths: name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateAgentRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateAssetGroupRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AgentResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' "400": description: Bad Request schema: @@ -11725,35 +11948,37 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update agent + summary: Update asset group tags: - - Agents - /agents/{id}/activate: - post: + - Asset Groups + /asset-groups/{id}/assets: + delete: consumes: - application/json - description: Activate an agent (admin action). Allows the agent to authenticate. + description: Remove one or more assets from an asset group parameters: - - description: Agent ID + - description: Asset Group ID in: path name: id required: true type: string + - description: Asset IDs to remove + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.RemoveAssetsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AgentResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: @@ -11764,32 +11989,36 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Activate agent + summary: Remove assets from group tags: - - Agents - /agents/{id}/disable: - post: + - Asset Groups + get: consumes: - application/json - description: Disable an agent (admin action). Prevents the agent from authenticating. + description: Get a paginated list of assets belonging to the group parameters: - - description: Agent ID + - description: Asset Group ID in: path name: id required: true type: string - - description: Disable reason - in: body - name: body - schema: - $ref: '#/definitions/internal_infra_http_handler.AgentDisableRequest' + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AgentResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_GroupAssetResponse' "400": description: Bad Request schema: @@ -11804,27 +12033,32 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Disable agent + summary: Get assets in group tags: - - Agents - /agents/{id}/regenerate-key: + - Asset Groups post: consumes: - application/json - description: Regenerate the API key for an agent. The old key will be invalidated. + description: Add one or more assets to an asset group parameters: - - description: Agent ID + - description: Asset Group ID in: path name: id required: true type: string + - description: Asset IDs to add + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.AddAssetsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AgentRegenerateAPIKeyResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' "400": description: Bad Request schema: @@ -11839,33 +12073,37 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Regenerate API key + summary: Add assets to group tags: - - Agents - /agents/{id}/revoke: - post: + - Asset Groups + /asset-groups/{id}/findings: + get: consumes: - application/json - description: Permanently revoke an agent's access (admin action). Cannot be - undone. + description: Get a paginated list of findings from assets in the group parameters: - - description: Agent ID + - description: Asset Group ID in: path name: id required: true type: string - - description: Revoke reason - in: body - name: body - schema: - $ref: '#/definitions/internal_infra_http_handler.AgentRevokeRequest' + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AgentResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_GroupFindingResponse' "400": description: Bad Request schema: @@ -11880,337 +12118,253 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Revoke agent - tags: - - Agents - /api/v1/agents/available-capabilities: - get: - description: Returns all unique capability names from all agents accessible - to the tenant - parameters: - - description: 'Include platform agents'' capabilities (default: true)' - in: query - name: include_platform - type: boolean - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.AvailableCapabilitiesResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal server error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Get available capabilities + summary: Get findings in group tags: - - agents - /api/v1/credentials: - get: - description: List credential leaks with filtering and pagination - parameters: - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Page size - in: query - name: page_size - type: integer - - description: Filter by severity (comma-separated) - in: query - name: severity - type: string - - description: Filter by state (comma-separated) - in: query - name: state - type: string - - description: Filter by source (comma-separated) - in: query - name: source - type: string - - description: Search in identifier - in: query - name: search - type: string - - description: Sort field (prefix - for desc) - in: query - name: sort - type: string - produces: + - Asset Groups + /asset-groups/bulk: + delete: + consumes: - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialListResult' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: List credential leaks - tags: - - Credentials - /api/v1/credentials/{id}: - get: - description: Get a single credential leak by its ID + description: Delete multiple asset groups at once parameters: - - description: Credential ID - in: path - name: id + - description: Bulk delete data + in: body + name: body required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkDeleteRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' - "401": - description: Unauthorized + additionalProperties: true + type: object + "400": + description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "500": + description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Get credential leak by ID + security: + - BearerAuth: [] + summary: Bulk delete asset groups tags: - - Credentials - /api/v1/credentials/{id}/accept: - post: + - Asset Groups + patch: consumes: - application/json - description: Mark a credential leak as accepted risk + description: Update multiple asset groups at once parameters: - - description: Credential ID - in: path - name: id - required: true - type: string - - description: Acceptance notes + - description: Bulk update data in: body - name: request + name: body + required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CredentialStateChangeRequest' + $ref: '#/definitions/internal_infra_http_handler.BulkUpdateRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' + additionalProperties: true + type: object "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "500": + description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Mark credential as accepted risk + security: + - BearerAuth: [] + summary: Bulk update asset groups tags: - - Credentials - /api/v1/credentials/{id}/false-positive: - post: + - Asset Groups + /asset-groups/stats: + get: consumes: - application/json - description: Mark a credential leak as a false positive - parameters: - - description: Credential ID - in: path - name: id - required: true - type: string - - description: Notes - in: body - name: request - schema: - $ref: '#/definitions/internal_infra_http_handler.CredentialStateChangeRequest' + description: Get aggregated statistics for asset groups produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' - "400": - description: Bad Request + $ref: '#/definitions/internal_infra_http_handler.AssetGroupStatsResponse' + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "500": + description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Mark credential as false positive - tags: - - Credentials - /api/v1/credentials/{id}/reactivate: - post: - description: Mark a resolved credential as active again + security: + - BearerAuth: [] + summary: Get asset group statistics + tags: + - Asset Groups + /asset-types: + get: + consumes: + - application/json + description: Retrieves a paginated list of system asset types. Asset types are + read-only configuration. Use active_only=true to get all active types without + pagination. parameters: - - description: Credential ID - in: path - name: id - required: true + - description: Return only active asset types (bypasses pagination) + in: query + name: active_only + type: boolean + - description: Include category details in response + in: query + name: include_category + type: boolean + - description: Search by name or code + in: query + name: search + type: string + - description: Filter by category ID + in: query + name: category_id + type: string + - description: Filter by exact code + in: query + name: code + type: string + - description: Filter by system type + in: query + name: is_system + type: boolean + - description: Filter by scannable flag + in: query + name: is_scannable + type: boolean + - description: Filter by discoverable flag + in: query + name: is_discoverable + type: boolean + - description: Sort field (e.g., 'name', '-display_order') + in: query + name: sort type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 50 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' + properties: + data: + items: + $ref: '#/definitions/internal_infra_http_handler.AssetTypeResponse' + type: array + page: + type: integer + per_page: + type: integer + total: + type: integer + total_pages: + type: integer + type: object "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Reactivate a resolved credential - tags: - - Credentials - /api/v1/credentials/{id}/related: - get: - description: Get all credentials related to the same identity - parameters: - - description: Credential ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' - type: array "401": description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "500": + description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Get related credential leaks + security: + - BearerAuth: [] + summary: List asset types tags: - - Credentials - /api/v1/credentials/{id}/resolve: - post: + - Asset Types + /asset-types/{id}: + get: consumes: - application/json - description: Mark a credential leak as resolved + description: Retrieves a single system asset type by its unique identifier parameters: - - description: Credential ID + - description: Asset Type ID (UUID) in: path name: id required: true type: string - - description: Resolution notes - in: body - name: request - schema: - $ref: '#/definitions/internal_infra_http_handler.CredentialStateChangeRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' + $ref: '#/definitions/internal_infra_http_handler.AssetTypeResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Mark credential as resolved - tags: - - Credentials - /api/v1/credentials/enums: - get: - description: Get available credential types, source types, and other enums - produces: - - application/json - responses: - "200": - description: OK + "500": + description: Internal Server Error schema: - additionalProperties: true - type: object - summary: Get available enum values + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Get an asset type by ID tags: - - Credentials - /api/v1/credentials/identities: + - Asset Types + /asset-types/categories: get: - description: List credential leaks grouped by identity (username/email) + consumes: + - application/json + description: Retrieves a paginated list of asset type categories. Use active_only=true + to get all active categories without pagination. parameters: - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Page size - in: query - name: page_size - type: integer - - description: Filter by state (comma-separated) + - description: Return only active categories (bypasses pagination) in: query - name: state - type: string - - description: Search in identifier + name: active_only + type: boolean + - description: Search by name or code in: query name: search type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.IdentityListResult' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: List credential leaks grouped by identity - tags: - - Credentials - /api/v1/credentials/identities/{identity}/exposures: - get: - description: Get all credential exposures for a specific identity with pagination - parameters: - - description: Identity (username or email) - in: path - name: identity - required: true - type: string - default: 1 description: Page number in: query name: page type: integer - default: 20 - description: Page size + description: Items per page in: query - name: page_size + name: per_page type: integer produces: - application/json @@ -12218,33 +12372,55 @@ paths: "200": description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialListResult' + properties: + data: + items: + $ref: '#/definitions/internal_infra_http_handler.CategoryResponse' + type: array + page: + type: integer + per_page: + type: integer + total: + type: integer + total_pages: + type: integer + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "401": description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Get exposures for a specific identity (lazy load) + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: List asset type categories tags: - - Credentials - /api/v1/credentials/import: - post: + - Asset Types + /asset-types/categories/{categoryId}: + get: consumes: - application/json - description: Import credential leaks with deduplication support + description: Retrieves a single asset type category by its unique identifier parameters: - - description: Import request - in: body - name: request + - description: Category ID (UUID) + in: path + name: categoryId required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CredentialImportRequest' + type: string produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_credential.ImportResult' + $ref: '#/definitions/internal_infra_http_handler.CategoryResponse' "400": description: Bad Request schema: @@ -12253,102 +12429,83 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Import credential leaks - tags: - - Credentials - /api/v1/credentials/import/csv: - post: - consumes: - - multipart/form-data - description: Import credential leaks from CSV file - parameters: - - description: CSV file - in: formData - name: file - required: true - type: file - - description: Deduplication strategy - in: query - name: dedup_strategy - type: string - - description: Reactivate resolved credentials - in: query - name: reactivate_resolved - type: boolean - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_credential.ImportResult' - "400": - description: Bad Request + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "500": + description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Import credential leaks from CSV - tags: - - Credentials - /api/v1/credentials/import/template: - get: - description: Download CSV template for credential import - produces: - - text/csv - responses: - "200": - description: CSV template - schema: - type: file - summary: Get CSV import template + security: + - BearerAuth: [] + summary: Get a category by ID tags: - - Credentials - /api/v1/credentials/stats: + - Asset Types + /assets: get: - description: Get statistics for credential leaks - produces: + consumes: - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Get credential leak statistics - tags: - - Credentials - /api/v1/groups: - get: - description: List all groups in the tenant with optional filtering + description: Retrieves a paginated list of assets for the current tenant parameters: - - description: Filter by group type + - description: Filter by name (partial match) in: query - name: type + name: name type: string - - description: Filter by active status + - description: Filter by types (comma-separated) in: query - name: active - type: boolean - - description: Search by name or slug + name: types + type: string + - description: Filter by criticalities (comma-separated) + in: query + name: criticalities + type: string + - description: Filter by statuses (comma-separated) + in: query + name: statuses + type: string + - description: Filter by scopes (comma-separated) + in: query + name: scopes + type: string + - description: Filter by exposures (comma-separated) + in: query + name: exposures + type: string + - description: Filter by tags (comma-separated) + in: query + name: tags + type: string + - description: Full-text search across name and description in: query name: search type: string - - default: 20 - description: Limit results + - description: Minimum risk score (0-100) in: query - name: limit + name: min_risk_score type: integer - - default: 0 - description: Offset for pagination + - description: Maximum risk score (0-100) in: query - name: offset + name: max_risk_score + type: integer + - description: Filter by whether asset has findings + in: query + name: has_findings + type: boolean + - description: Sort field (e.g., -created_at, name, -risk_score) + in: query + name: sort + type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + maximum: 100 + name: per_page type: integer produces: - application/json @@ -12356,68 +12513,125 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.GroupListResponse' - summary: List groups + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List assets tags: - - groups + - Assets post: consumes: - application/json - description: Create a new group for access control + description: Creates a new asset for the current tenant parameters: - - description: Group details + - description: Asset data in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateGroupRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateAssetRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.GroupResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden + additionalProperties: + type: string + type: object + "409": + description: Conflict schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Create a new group + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create asset tags: - - groups - /api/v1/groups/{groupId}: + - Assets + /assets/{id}: delete: - description: Delete a group + description: Deletes an asset by ID parameters: - - description: Group ID + - description: Asset ID in: path - name: groupId + name: id required: true type: string responses: "204": description: No Content + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Delete a group + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete asset tags: - - groups + - Assets get: - description: Get a group's details + description: Retrieves an asset by ID parameters: - - description: Group ID + - description: Asset ID in: path - name: groupId + name: id required: true type: string produces: @@ -12426,55 +12640,95 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.GroupResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Get a group by ID - tags: - - groups - put: - consumes: - - application/json - description: Update a group's details - parameters: - - description: Group ID + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get asset + tags: + - Assets + put: + consumes: + - application/json + description: Updates an existing asset + parameters: + - description: Asset ID in: path - name: groupId + name: id required: true type: string - - description: Update details + - description: Asset data in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateGroupRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateAssetRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.GroupResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Update a group + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update asset tags: - - groups - /api/v1/groups/{groupId}/assets: - get: - description: List all assets that belong to this group + - Assets + /assets/{id}/activate: + post: + description: Activates an asset parameters: - - description: Group ID + - description: Asset ID in: path - name: groupId + name: id required: true type: string produces: @@ -12483,242 +12737,270 @@ paths: "200": description: OK schema: - items: - $ref: '#/definitions/internal_infra_http_handler.AssetOwnerResponse' - type: array - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: List assets assigned to a group - tags: - - groups - post: - consumes: - - application/json - description: Assign an asset to the group with specified ownership type - parameters: - - description: Group ID - in: path - name: groupId - required: true - type: string - - description: Asset assignment details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.AssignAssetRequest' - produces: - - application/json - responses: - "204": - description: No Content + $ref: '#/definitions/internal_infra_http_handler.AssetResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + additionalProperties: + type: string + type: object + "401": + description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Assign an asset to a group - tags: - - groups - /api/v1/groups/{groupId}/assets/{assetId}: - delete: - description: Remove an asset ownership from the group - parameters: - - description: Group ID - in: path - name: groupId - required: true - type: string - - description: Asset ID - in: path - name: assetId - required: true - type: string - responses: - "204": - description: No Content + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Remove an asset from a group + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Activate asset tags: - - groups - put: - consumes: - - application/json - description: Update the ownership type for an asset in a group + - Assets + /assets/{id}/archive: + post: + description: Archives an asset parameters: - - description: Group ID - in: path - name: groupId - required: true - type: string - description: Asset ID in: path - name: assetId + name: id required: true type: string - - description: Ownership details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateAssetOwnershipRequest' produces: - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.AssetResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Update asset ownership type + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Archive asset tags: - - groups - /api/v1/groups/{groupId}/members: + - Assets + /assets/{id}/components: get: - description: List all members of a group with user details + description: Retrieves all components for an asset parameters: - - description: Group ID + - description: Asset ID in: path - name: groupId + name: id required: true type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - items: - $ref: '#/definitions/internal_infra_http_handler.GroupMemberWithUserResponse' - type: array + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: List group members + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List asset components tags: - - groups + - Components + /assets/{id}/deactivate: post: - consumes: - - application/json - description: Add a user as a member of the group + description: Deactivates an asset parameters: - - description: Group ID + - description: Asset ID in: path - name: groupId + name: id required: true type: string - - description: Member details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.AddGroupMemberRequest' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.GroupMemberResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Add a member to a group + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Deactivate asset tags: - - groups - /api/v1/groups/{groupId}/members/{userId}: - delete: - description: Remove a user from the group + - Assets + /assets/{id}/findings: + get: + description: Retrieves all findings for an asset parameters: - - description: Group ID + - description: Asset ID in: path - name: groupId + name: id required: true type: string - - description: User ID - in: path - name: userId - required: true + - description: Sort field + in: query + name: sort type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + additionalProperties: true + type: object "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Remove a member from a group + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List asset findings tags: - - groups - put: - consumes: - - application/json - description: Update the role of a group member + - Findings + /assets/{id}/full: + get: + description: Retrieves an asset with its repository extension (if applicable) parameters: - - description: Group ID - in: path - name: groupId - required: true - type: string - - description: User ID + - description: Asset ID in: path - name: userId + name: id required: true type: string - - description: Role update - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateGroupMemberRoleRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.GroupMemberResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetWithRepositoryResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Update a member's role + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get asset with repository tags: - - groups - /api/v1/groups/{groupId}/permission-sets: + - Assets + /assets/{id}/repository: get: - description: Get all permission sets assigned to the group with full details + description: Retrieves the repository extension for an asset parameters: - - description: Group ID + - description: Asset ID in: path - name: groupId + name: id required: true type: string produces: @@ -12727,170 +13009,203 @@ paths: "200": description: OK schema: - items: - $ref: '#/definitions/internal_infra_http_handler.PermissionSetResponse' - type: array + $ref: '#/definitions/internal_infra_http_handler.RepositoryExtensionResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: List permission sets assigned to a group + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get repository extension tags: - - groups - post: + - Assets + put: consumes: - application/json - description: Assign a permission set to the group + description: Updates the repository extension for an asset parameters: - - description: Group ID + - description: Asset ID in: path - name: groupId + name: id required: true type: string - - description: Permission set details + - description: Repository extension data in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.AssignPermissionSetRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateRepositoryExtensionRequest' produces: - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.RepositoryExtensionResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Assign a permission set to a group + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update repository extension tags: - - groups - /api/v1/groups/{groupId}/permission-sets/{permissionSetId}: - delete: - description: Remove a permission set assignment from the group + - Assets + /assets/{id}/scan: + post: + description: Triggers a security scan for the repository asset parameters: - - description: Group ID - in: path - name: groupId - required: true - type: string - - description: Permission Set ID + - description: Asset ID in: path - name: permissionSetId + name: id required: true type: string - responses: - "204": - description: No Content - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Remove a permission set from a group - tags: - - groups - /api/v1/me/assets: - get: - description: List all assets the current user can access through their group - memberships produces: - application/json responses: - "200": - description: OK + "202": + description: Accepted schema: - additionalProperties: true - type: object - summary: List current user's accessible assets - tags: - - assets - /api/v1/me/groups: - get: - description: List all groups the current user belongs to - produces: - - application/json - responses: - "200": - description: OK + $ref: '#/definitions/internal_infra_http_handler.ScanResponse' + "400": + description: Bad Request schema: - items: - $ref: '#/definitions/internal_infra_http_handler.GroupWithRoleResponse' - type: array - summary: List current user's groups - tags: - - groups - /api/v1/me/permissions: - get: - description: Get all effective permissions for the current user based on their - group memberships - produces: - - application/json - responses: - "200": - description: OK + additionalProperties: + type: string + type: object + "401": + description: Unauthorized schema: - $ref: '#/definitions/internal_infra_http_handler.EffectivePermissionsResponse' - summary: Get effective permissions for current user + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Trigger security scan tags: - - permissions - /api/v1/permission-sets: + - Assets + /assets/{id}/services: get: - description: List all permission sets for the tenant + consumes: + - application/json + description: Retrieves all services discovered on a specific asset parameters: - - description: Include system permission sets - in: query - name: include_system - type: boolean - - description: Filter by type - in: query - name: type - type: string - - description: Search by name - in: query - name: search + - description: Asset ID (UUID) + in: path + name: id + required: true type: string - - default: 20 - description: Limit results - in: query - name: limit - type: integer - - default: 0 - description: Offset for pagination - in: query - name: offset - type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.PermissionSetListResponse' - summary: List permission sets + properties: + data: + items: + $ref: '#/definitions/internal_infra_http_handler.AssetServiceResponse' + type: array + total: + type: integer + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: List services for an asset tags: - - permission-sets + - Asset Services post: consumes: - application/json - description: Create a new permission set for access control + description: Creates a new service entry for a specific asset (e.g., discovered + port/protocol) parameters: - - description: Permission set details + - description: Asset ID (UUID) + in: path + name: id + required: true + type: string + - description: Service details in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreatePermissionSetRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateAssetServiceRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.PermissionSetResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetServiceResponse' "400": description: Bad Request schema: @@ -12899,40 +13214,28 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Create a new permission set - tags: - - permission-sets - /api/v1/permission-sets/{id}: - delete: - description: Delete a permission set - parameters: - - description: Permission set ID - in: path - name: id - required: true - type: string - responses: - "204": - description: No Content - "400": - description: Bad Request + "409": + description: Service already exists for this port schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "500": + description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Delete a permission set + security: + - BearerAuth: [] + summary: Create a service for an asset tags: - - permission-sets + - Asset Services + /assets/{id}/sla-policy: get: - description: Get detailed information about a permission set + description: Gets the SLA policy for a specific asset (or default if not set) parameters: - - description: Permission set ID + - description: Asset ID in: path name: id required: true @@ -12943,281 +13246,290 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.PermissionSetWithItemsResponse' + $ref: '#/definitions/internal_infra_http_handler.SLAPolicyResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Get a permission set by ID + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get asset SLA policy tags: - - permission-sets - put: + - SLA Policies + /assets/{id}/state-history: + get: consumes: - application/json - description: Update an existing permission set + description: Retrieves all state changes for a specific asset with optional + filtering parameters: - - description: Permission set ID + - description: Asset ID (UUID) in: path name: id required: true type: string - - description: Update details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdatePermissionSetRequest' + - description: Filter by change type + in: query + name: change_type + type: string + - description: Filter by source + in: query + name: source + type: string + - description: Start time (RFC3339) + in: query + name: from + type: string + - description: End time (RFC3339) + in: query + name: to + type: string + - default: 50 + description: Maximum results (max 1000) + in: query + name: limit + type: integer + - default: 0 + description: Pagination offset + in: query + name: offset + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.PermissionSetResponse' + properties: + data: + items: + $ref: '#/definitions/internal_infra_http_handler.StateChangeResponse' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Update a permission set + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: List state history for an asset tags: - - permission-sets - /api/v1/permission-sets/{id}/permissions: + - Asset State History + /assets/{id}/sync: post: - consumes: - - application/json - description: Add a permission to a permission set + description: Syncs repository metadata from the connected SCM provider parameters: - - description: Permission set ID + - description: Asset ID in: path name: id required: true type: string - - description: Permission details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.AddPermissionRequest' produces: - application/json responses: - "201": - description: Created - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "200": + description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Add a permission to a set - tags: - - permission-sets - /api/v1/permission-sets/{id}/permissions/{permissionId}: - delete: - description: Remove a permission from a permission set - parameters: - - description: Permission set ID - in: path - name: id - required: true - type: string - - description: Permission ID - in: path - name: permissionId - required: true - type: string - responses: - "204": - description: No Content + $ref: '#/definitions/internal_infra_http_handler.SyncResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Remove a permission from a set - tags: - - permission-sets - /api/v1/permission-sets/system: - get: - description: List all system-defined permission sets - produces: - - application/json - responses: - "200": - description: OK + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error schema: - items: - $ref: '#/definitions/internal_infra_http_handler.PermissionSetResponse' - type: array - summary: List system permission sets + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Sync repository from SCM tags: - - permission-sets - /asset-groups: - get: + - Assets + /assets/bulk/status: + post: consumes: - application/json - description: Get a paginated list of asset groups for the current tenant + description: Updates the status of multiple assets at once parameters: - - description: Search by name - in: query - name: search - type: string - - description: Filter by environments (comma-separated) - in: query - name: environments - type: string - - description: Filter by criticalities (comma-separated) - in: query - name: criticalities - type: string - - description: Filter by business unit - in: query - name: business_unit - type: string - - description: Filter by owner - in: query - name: owner - type: string - - description: Filter by tags (comma-separated) - in: query - name: tags - type: string - - description: Filter groups with findings - in: query - name: has_findings - type: boolean - - description: Minimum risk score - in: query - name: min_risk_score - type: integer - - description: Maximum risk score - in: query - name: max_risk_score - type: integer - - description: Sort field (name, created_at, risk_score) - in: query - name: sort - type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer + - description: Bulk update data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.AssetBulkStatusRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_AssetGroupResponse' + $ref: '#/definitions/internal_infra_http_handler.AssetBulkStatusResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "500": description: Internal Server Error schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: List asset groups + summary: Bulk update asset status tags: - - Asset Groups + - Assets + /assets/bulk/sync: post: consumes: - application/json - description: Create a new asset group + description: Syncs multiple repository assets from their SCM providers in a + single request parameters: - - description: Asset group data + - description: Asset IDs to sync in: body - name: body + name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateAssetGroupRequest' + $ref: '#/definitions/internal_infra_http_handler.BulkSyncRequest' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' + $ref: '#/definitions/internal_infra_http_handler.BulkSyncResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Conflict + additionalProperties: + type: string + type: object + "401": + description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "500": description: Internal Server Error schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Create asset group + summary: Bulk sync repositories tags: - - Asset Groups - /asset-groups/{id}: - delete: + - Assets + /assets/repository: + post: consumes: - application/json - description: Delete an asset group + description: Creates a new repository asset with its extension data parameters: - - description: Asset Group ID - in: path - name: id + - description: Repository asset data + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.CreateRepositoryAssetRequest' produces: - application/json responses: - "204": - description: No Content + "201": + description: Created + schema: + $ref: '#/definitions/internal_infra_http_handler.AssetWithRepositoryResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + additionalProperties: + type: string + type: object + "401": + description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object "500": description: Internal Server Error schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Delete asset group + summary: Create repository asset tags: - - Asset Groups + - Assets + /assets/stats: get: - consumes: - - application/json - description: Get a single asset group by ID + description: Returns aggregated statistics for assets parameters: - - description: Asset Group ID - in: path - name: id - required: true + - description: Filter by types (comma-separated) + in: query + name: types + type: string + - description: Filter by tags (comma-separated, overlap) + in: query + name: tags type: string produces: - application/json @@ -13225,53 +13537,40 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + $ref: '#/definitions/internal_infra_http_handler.AssetStatsResponse' + "401": + description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Get asset group + summary: Get asset statistics tags: - - Asset Groups - patch: - consumes: - - application/json - description: Update an existing asset group - parameters: - - description: Asset Group ID - in: path - name: id - required: true - type: string - - description: Update data - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateAssetGroupRequest' + - Assets + /attack-surface/attack-paths: + get: + description: Computes reachability-based attack path scores for all assets produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' + $ref: '#/definitions/internal_infra_http_handler.AttackPathScoringResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -13280,43 +13579,26 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update asset group + summary: Get attack path scoring tags: - - Asset Groups - /asset-groups/{id}/assets: + - Attack Surface + /attack-surface/stats: get: - consumes: - - application/json - description: Get a paginated list of assets belonging to the group - parameters: - - description: Asset Group ID - in: path - name: id - required: true - type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer + description: Returns attack surface statistics including total assets, exposed + services, critical exposures, and risk score produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_GroupAssetResponse' + $ref: '#/definitions/internal_infra_http_handler.AttackSurfaceStatsResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -13325,103 +13607,136 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get assets in group + summary: Get attack surface statistics tags: - - Asset Groups - post: - consumes: - - application/json - description: Add one or more assets to an asset group + - Attack Surface + /audit-logs: + get: + description: Returns paginated audit logs for the current tenant parameters: - - description: Asset Group ID - in: path - name: id - required: true + - description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer + - description: Filter by actor ID + in: query + name: actor_id + type: string + - description: Filter by action + in: query + name: action + type: string + - description: Filter by resource type + in: query + name: resource_type + type: string + - description: Filter by resource ID + in: query + name: resource_id + type: string + - description: Filter by result + in: query + name: result + type: string + - description: Filter by severity + in: query + name: severity + type: string + - description: Filter since (RFC3339) + in: query + name: since + type: string + - description: Filter until (RFC3339) + in: query + name: until + type: string + - description: Search term + in: query + name: search type: string - - description: Asset IDs to add - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.AddAssetsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' + $ref: '#/definitions/internal_infra_http_handler.AuditLogListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + "401": + description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Add assets to group + summary: List audit logs tags: - - Asset Groups - /asset-groups/{id}/assets/remove: - post: - consumes: - - application/json - description: Remove one or more assets from an asset group + - Audit Logs + /audit-logs/{id}: + get: + description: Returns a single audit log by ID parameters: - - description: Asset Group ID + - description: Audit Log ID in: path name: id required: true type: string - - description: Asset IDs to remove - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.RemoveAssetsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetGroupResponse' + $ref: '#/definitions/internal_infra_http_handler.AuditLogResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Remove assets from group + summary: Get audit log tags: - - Asset Groups - /asset-groups/{id}/findings: + - Audit Logs + /audit-logs/resource/{type}/{id}: get: - consumes: - - application/json - description: Get a paginated list of findings from assets in the group + description: Returns audit history for a specific resource parameters: - - description: Asset Group ID + - description: Resource type + in: path + name: type + required: true + type: string + - description: Resource ID in: path name: id required: true type: string - - default: 1 - description: Page number + - description: Page number in: query name: page type: integer @@ -13436,419 +13751,288 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_GroupFindingResponse' + $ref: '#/definitions/internal_infra_http_handler.AuditLogListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Get findings in group + summary: Get resource history tags: - - Asset Groups - /asset-groups/bulk: - patch: - consumes: - - application/json - description: Update multiple asset groups at once - parameters: - - description: Bulk update data - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.BulkUpdateRequest' + - Audit Logs + /audit-logs/stats: + get: + description: Returns audit log statistics for the last 7 days produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_infra_http_handler.AuditStatsResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Bulk update asset groups + summary: Get audit stats tags: - - Asset Groups - /asset-groups/bulk/delete: - post: - consumes: - - application/json - description: Delete multiple asset groups at once + - Audit Logs + /audit-logs/user/{id}: + get: + description: Returns audit logs for a specific user parameters: - - description: Bulk delete data - in: body - name: body + - description: User ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.BulkDeleteRequest' + type: string + - description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_infra_http_handler.AuditLogListResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Bulk delete asset groups + summary: Get user activity tags: - - Asset Groups - /asset-groups/stats: - get: + - Audit Logs + /auth/forgot-password: + post: consumes: - application/json - description: Get aggregated statistics for asset groups + description: Sends password reset email + parameters: + - description: Email address + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.ForgotPasswordRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetGroupStatsResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get asset group statistics + additionalProperties: + type: string + type: object + summary: Forgot password tags: - - Asset Groups - /asset-types: + - Authentication + /auth/info: get: + description: Returns authentication provider information + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.AuthInfoResponse' + summary: Auth info + tags: + - Authentication + /auth/login: + post: consumes: - application/json - description: Retrieves a paginated list of system asset types. Asset types are - read-only configuration. Use active_only=true to get all active types without - pagination. + description: Authenticates a user and returns refresh token and tenant list parameters: - - description: Return only active asset types (bypasses pagination) - in: query - name: active_only - type: boolean - - description: Include category details in response - in: query - name: include_category - type: boolean - - description: Search by name or code - in: query - name: search - type: string - - description: Filter by category ID - in: query - name: category_id - type: string - - description: Filter by exact code - in: query - name: code - type: string - - description: Filter by system type - in: query - name: is_system - type: boolean - - description: Filter by scannable flag - in: query - name: is_scannable - type: boolean - - description: Filter by discoverable flag - in: query - name: is_discoverable - type: boolean - - description: Sort field (e.g., 'name', '-display_order') - in: query - name: sort - type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 50 - description: Items per page - in: query - name: per_page - type: integer + - description: Login credentials + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.LoginRequest' produces: - application/json responses: "200": description: OK schema: - properties: - data: - items: - $ref: '#/definitions/internal_infra_http_handler.AssetTypeResponse' - type: array - page: - type: integer - per_page: - type: integer - total: - type: integer - total_pages: - type: integer - type: object + $ref: '#/definitions/internal_infra_http_handler.LoginResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + "403": + description: Forbidden schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: List asset types + additionalProperties: + type: string + type: object + summary: User login tags: - - Asset Types - /asset-types/{id}: - get: - consumes: - - application/json - description: Retrieves a single system asset type by its unique identifier - parameters: - - description: Asset Type ID (UUID) - in: path - name: id - required: true - type: string + - Authentication + /auth/logout: + post: + description: Logs out the current user and invalidates the session produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetTypeResponse' + additionalProperties: + type: string + type: object "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Get an asset type by ID + summary: User logout tags: - - Asset Types - /asset-types/categories: + - Authentication + /auth/oauth/{provider}/authorize: get: - consumes: - - application/json - description: Retrieves a paginated list of asset type categories. Use active_only=true - to get all active categories without pagination. + description: Returns authorization URL for OAuth login with the specified provider parameters: - - description: Return only active categories (bypasses pagination) - in: query - name: active_only - type: boolean - - description: Search by name or code - in: query - name: search + - description: OAuth provider (google, github, gitlab) + in: path + name: provider + required: true type: string - - default: 1 - description: Page number + - description: Callback URL after authorization in: query - name: page - type: integer - - default: 20 - description: Items per page + name: redirect_uri + type: string + - description: Final redirect URL after login in: query - name: per_page - type: integer + name: final_redirect + type: string produces: - application/json responses: "200": description: OK schema: - properties: - data: - items: - $ref: '#/definitions/internal_infra_http_handler.CategoryResponse' - type: array - page: - type: integer - per_page: - type: integer - total: - type: integer - total_pages: - type: integer - type: object + $ref: '#/definitions/internal_infra_http_handler.AuthorizeResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: List asset type categories + additionalProperties: + type: string + type: object + summary: Get OAuth authorization URL tags: - - Asset Types - /asset-types/categories/{categoryId}: - get: + - OAuth + /auth/oauth/{provider}/callback: + post: consumes: - application/json - description: Retrieves a single asset type category by its unique identifier + description: Handles the OAuth callback after user authorization parameters: - - description: Category ID (UUID) + - description: OAuth provider in: path - name: categoryId + name: provider required: true type: string + - description: Callback data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.CallbackRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CategoryResponse' + $ref: '#/definitions/internal_infra_http_handler.CallbackResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + summary: OAuth callback handler + tags: + - OAuth + /auth/oauth/providers: + get: + description: Returns list of available and configured OAuth providers + produces: + - application/json + responses: + "200": + description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get a category by ID + $ref: '#/definitions/internal_infra_http_handler.ProvidersResponse' + summary: List OAuth providers tags: - - Asset Types - /assets: + - OAuth + /auth/providers: get: + description: Reports which social OAuth providers (and the Entra SSO env fallback) + are configured, so the UI can hide dead login buttons. Booleans only — no + secrets. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.AuthProvidersResponse' + summary: Public login-provider capability snapshot + tags: + - OAuth + /auth/refresh: + post: consumes: - application/json - description: Retrieves a paginated list of assets for the current tenant + description: Refreshes access token and rotates refresh token parameters: - - description: Filter by name (partial match) - in: query - name: name - type: string - - description: Filter by types (comma-separated) - in: query - name: types - type: string - - description: Filter by criticalities (comma-separated) - in: query - name: criticalities - type: string - - description: Filter by statuses (comma-separated) - in: query - name: statuses - type: string - - description: Filter by scopes (comma-separated) - in: query - name: scopes - type: string - - description: Filter by exposures (comma-separated) - in: query - name: exposures - type: string - - description: Filter by tags (comma-separated) - in: query - name: tags - type: string - - description: Full-text search across name and description - in: query - name: search - type: string - - description: Minimum risk score (0-100) - in: query - name: min_risk_score - type: integer - - description: Maximum risk score (0-100) - in: query - name: max_risk_score - type: integer - - description: Filter by whether asset has findings - in: query - name: has_findings - type: boolean - - description: Sort field (e.g., -created_at, name, -risk_score) - in: query - name: sort - type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - maximum: 100 - name: per_page - type: integer + - description: Refresh token data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.RefreshTokenRequest' produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_infra_http_handler.RefreshTokenResponse' "400": description: Bad Request schema: @@ -13861,76 +14045,92 @@ paths: additionalProperties: type: string type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: List assets + summary: Refresh token tags: - - Assets + - Authentication + /auth/register: post: consumes: - application/json - description: Creates a new asset for the current tenant + description: Registers a new user with email and password parameters: - - description: Asset data + - description: Registration data in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateAssetRequest' + $ref: '#/definitions/internal_infra_http_handler.RegisterRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.AssetResponse' + $ref: '#/definitions/internal_infra_http_handler.RegisterResponse' "400": description: Bad Request schema: additionalProperties: type: string type: object - "401": - description: Unauthorized + "409": + description: Conflict schema: additionalProperties: type: string type: object - "409": - description: Conflict + summary: Register user + tags: + - Authentication + /auth/reset-password: + post: + consumes: + - application/json + description: Resets password using token + parameters: + - description: Reset token and new password + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.ResetPasswordRequest' + produces: + - application/json + responses: + "200": + description: OK schema: additionalProperties: type: string type: object - "500": - description: Internal Server Error + "400": + description: Bad Request schema: additionalProperties: type: string type: object - security: - - BearerAuth: [] - summary: Create asset + summary: Reset password tags: - - Assets - /assets/{id}: - delete: - description: Deletes an asset by ID + - Authentication + /auth/token: + post: + consumes: + - application/json + description: Exchanges refresh token for tenant-scoped access token parameters: - - description: Asset ID - in: path - name: id + - description: Token exchange data + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.ExchangeTokenRequest' + produces: + - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.ExchangeTokenResponse' "400": description: Bad Request schema: @@ -13943,169 +14143,150 @@ paths: additionalProperties: type: string type: object - "404": - description: Not Found - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Delete asset + summary: Exchange token tags: - - Assets - get: - description: Retrieves an asset by ID + - Authentication + /auth/verify-email: + post: + consumes: + - application/json + description: Verifies user email with token parameters: - - description: Asset ID - in: path - name: id + - description: Verification token + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.VerifyEmailRequest' produces: - application/json responses: "200": description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.AssetResponse' - "400": - description: Bad Request schema: additionalProperties: type: string type: object - "401": - description: Unauthorized + "400": + description: Bad Request schema: additionalProperties: type: string type: object - "404": - description: Not Found + summary: Verify email + tags: + - Authentication + /auth/ws-token: + get: + description: Returns a short-lived token for WebSocket authentication + produces: + - application/json + responses: + "200": + description: OK schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error + $ref: '#/definitions/internal_infra_http_handler.WSTokenResponse' + "401": + description: Unauthorized schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get asset + summary: Get WebSocket token tags: - - Assets - put: + - Authentication + /commands: + get: consumes: - application/json - description: Updates an existing asset + description: Get a paginated list of commands parameters: - - description: Asset ID - in: path - name: id - required: true + - description: Filter by agent ID + in: query + name: agent_id type: string - - description: Asset data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateAssetRequest' + - description: Filter by type (scan, collect, health_check, config_update, cancel) + in: query + name: type + type: string + - description: Filter by status (pending, running, completed, failed, canceled) + in: query + name: status + type: string + - description: Filter by priority (low, normal, high, critical) + in: query + name: priority + type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_CommandResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "404": - description: Not Found - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update asset + summary: List commands tags: - - Assets - /assets/{id}/activate: + - Commands post: - description: Activates an asset + consumes: + - application/json + description: Create a new command to be executed by an agent parameters: - - description: Asset ID - in: path - name: id + - description: Command data + in: body + name: body required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.CreateCommandRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.AssetResponse' + $ref: '#/definitions/internal_infra_http_handler.CommandResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "404": - description: Not Found - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Activate asset + summary: Create command tags: - - Assets - /assets/{id}/archive: - post: - description: Archives an asset + - Commands + /commands/{id}: + delete: + consumes: + - application/json + description: Delete a command parameters: - - description: Asset ID + - description: Command ID in: path name: id required: true @@ -14113,88 +14294,66 @@ paths: produces: - application/json responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.AssetResponse' + "204": + description: No Content "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Archive asset + summary: Delete command tags: - - Assets - /assets/{id}/components: + - Commands get: - description: Retrieves all components for an asset + consumes: + - application/json + description: Get a single command by ID parameters: - - description: Asset ID + - description: Command ID in: path name: id required: true type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_infra_http_handler.CommandResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List asset components + summary: Get command tags: - - Components - /assets/{id}/deactivate: + - Commands + /commands/{id}/cancel: post: - description: Deactivates an asset + consumes: + - application/json + description: Cancel a pending or running command parameters: - - description: Asset ID + - description: Command ID in: path name: id required: true @@ -14205,48 +14364,55 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetResponse' + $ref: '#/definitions/internal_infra_http_handler.CommandResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Deactivate asset + summary: Cancel command tags: - - Assets - /assets/{id}/findings: + - Commands + /components: get: - description: Retrieves all findings for an asset + description: Retrieves a paginated list of components for the current tenant parameters: - - description: Asset ID - in: path - name: id - required: true + - description: Filter by asset ID + in: query + name: asset_id type: string - - description: Sort field + - description: Filter by name in: query - name: sort + name: name + type: string + - description: Filter by ecosystems (comma-separated) + in: query + name: ecosystems + type: string + - description: Filter by statuses (comma-separated) + in: query + name: statuses + type: string + - description: Filter by dependency types + in: query + name: dependency_types + type: string + - description: Filter by has vulnerabilities + in: query + name: has_vulnerabilities + type: boolean + - description: Filter by licenses (comma-separated) + in: query + name: licenses type: string - default: 1 description: Page number @@ -14272,33 +14438,35 @@ paths: additionalProperties: type: string type: object - "404": - description: Not Found + "401": + description: Unauthorized schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: List asset findings + summary: List components tags: - - Findings - /assets/{id}/full: - get: - description: Retrieves an asset with its repository extension (if applicable) + - Components + post: + consumes: + - application/json + description: Creates a new component parameters: - - description: Asset ID - in: path - name: id + - description: Component data + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.CreateComponentRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.AssetWithRepositoryResponse' + $ref: '#/definitions/internal_infra_http_handler.ComponentResponse' "400": description: Bad Request schema: @@ -14311,28 +14479,50 @@ paths: additionalProperties: type: string type: object - "404": - description: Not Found + "409": + description: Conflict schema: additionalProperties: type: string type: object - "500": - description: Internal Server Error + security: + - BearerAuth: [] + summary: Create component + tags: + - Components + /components/{id}: + delete: + description: Deletes a component + parameters: + - description: Component ID + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get asset with repository + summary: Delete component tags: - - Assets - /assets/{id}/repository: + - Components get: - description: Retrieves the repository extension for an asset + description: Retrieves a component by ID parameters: - - description: Asset ID + - description: Component ID in: path name: id required: true @@ -14343,321 +14533,339 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.RepositoryExtensionResponse' + $ref: '#/definitions/internal_infra_http_handler.ComponentResponse' "400": description: Bad Request schema: additionalProperties: type: string type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object "404": description: Not Found schema: additionalProperties: type: string type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object security: - BearerAuth: [] - summary: Get repository extension + summary: Get component tags: - - Assets + - Components put: consumes: - application/json - description: Updates the repository extension for an asset + description: Updates a component parameters: - - description: Asset ID + - description: Component ID in: path name: id required: true type: string - - description: Repository extension data + - description: Component data in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateRepositoryExtensionRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateComponentRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.RepositoryExtensionResponse' + $ref: '#/definitions/internal_infra_http_handler.ComponentResponse' "400": description: Bad Request schema: additionalProperties: type: string type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object "404": description: Not Found schema: additionalProperties: type: string type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object security: - BearerAuth: [] - summary: Update repository extension + summary: Update component tags: - - Assets - /assets/{id}/scan: - post: - description: Triggers a security scan for the repository asset + - Components + /components/{id}/assets: + get: + description: Returns the assets in the current tenant that use the given global + component parameters: - - description: Asset ID + - description: Global component ID in: path name: id required: true type: string + - description: Only return assets with open findings for this component + in: query + name: at_risk_only + type: boolean + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: - "202": - description: Accepted + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanResponse' + additionalProperties: true + type: object "400": description: Bad Request schema: additionalProperties: type: string type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object "404": description: Not Found schema: additionalProperties: type: string type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object security: - BearerAuth: [] - summary: Trigger security scan + summary: List assets that use a component tags: - - Assets - /assets/{id}/services: + - Components + /components/{id}/vulnerabilities: get: - consumes: - - application/json - description: Retrieves all services discovered on a specific asset + description: Returns CVEs affecting a global component within the current tenant. parameters: - - description: Asset ID (UUID) + - description: Global component ID in: path name: id required: true type: string + - description: Include CVEs only seen in closed findings + in: query + name: include_resolved + type: boolean + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - properties: - data: - items: - $ref: '#/definitions/internal_infra_http_handler.AssetServiceResponse' - type: array - total: - type: integer + additionalProperties: true type: object "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: List services for an asset + summary: List CVEs that affect a component tags: - - Asset Services - post: - consumes: - - application/json - description: Creates a new service entry for a specific asset (e.g., discovered - port/protocol) - parameters: - - description: Asset ID (UUID) - in: path - name: id - required: true - type: string - - description: Service details - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CreateAssetServiceRequest' - produces: + - Components + /components/ecosystems: + get: + description: Retrieves per-ecosystem statistics for the tenant + produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetServiceResponse' + items: + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_component.EcosystemStats' + type: array "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get ecosystem statistics + tags: + - Components + /components/licenses: + get: + description: Retrieves license distribution statistics for the tenant + produces: + - application/json + responses: + "200": + description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Service already exists for this port + items: + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_component.LicenseStats' + type: array + "401": + description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get license statistics + tags: + - Components + /components/stats: + get: + description: Retrieves aggregated component statistics for the tenant + produces: + - application/json + responses: + "200": + description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_component.ComponentStats' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Create a service for an asset + summary: Get component statistics tags: - - Asset Services - /assets/{id}/sla-policy: + - Components + /components/vulnerable: get: - description: Gets the SLA policy for a specific asset (or default if not set) + description: Retrieves components with vulnerability details for the tenant parameters: - - description: Asset ID - in: path - name: id - required: true - type: string + - default: 10 + description: Limit results + in: query + name: limit + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.SLAPolicyResponse' + items: + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_component.VulnerableComponent' + type: array "400": description: Bad Request schema: additionalProperties: type: string type: object - "404": - description: Not Found + "401": + description: Unauthorized schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get asset SLA policy + summary: Get vulnerable components tags: - - SLA Policies - /assets/{id}/state-history: + - Components + /credentials: get: - consumes: - - application/json - description: Retrieves all state changes for a specific asset with optional - filtering + description: List credential leaks with filtering and pagination parameters: - - description: Asset ID (UUID) - in: path - name: id - required: true + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Page size + in: query + name: page_size + type: integer + - description: Filter by severity (comma-separated) + in: query + name: severity type: string - - description: Filter by change type + - description: Filter by state (comma-separated) in: query - name: change_type + name: state type: string - - description: Filter by source + - description: Filter by source (comma-separated) in: query name: source type: string - - description: Start time (RFC3339) + - description: Search in identifier in: query - name: from + name: search type: string - - description: End time (RFC3339) + - description: Sort field (prefix - for desc) in: query - name: to + name: sort type: string - - default: 50 - description: Maximum results (max 1000) - in: query - name: limit - type: integer - - default: 0 - description: Pagination offset - in: query - name: offset - type: integer produces: - application/json responses: "200": description: OK schema: - properties: - data: - items: - $ref: '#/definitions/internal_infra_http_handler.StateChangeResponse' - type: array - limit: - type: integer - offset: - type: integer - total: - type: integer - type: object - "400": - description: Bad Request + $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialListResult' + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: List credential leaks + tags: + - Credentials + /credentials/{id}: + get: + description: Get a single credential leak by its ID + parameters: + - description: Credential ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' "401": description: Unauthorized schema: @@ -14666,299 +14874,200 @@ paths: description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: List state history for an asset + summary: Get credential leak by ID tags: - - Asset State History - /assets/{id}/sync: + - Credentials + /credentials/{id}/accept: post: - description: Syncs repository metadata from the connected SCM provider + consumes: + - application/json + description: Mark a credential leak as accepted risk parameters: - - description: Asset ID + - description: Credential ID in: path name: id required: true type: string + - description: Acceptance notes + in: body + name: request + schema: + $ref: '#/definitions/internal_infra_http_handler.CredentialStateChangeRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.SyncResponse' + $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Sync repository from SCM + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Mark credential as accepted risk tags: - - Assets - /assets/bulk-sync: + - Credentials + /credentials/{id}/false-positive: post: consumes: - application/json - description: Syncs multiple repository assets from their SCM providers in a - single request + description: Mark a credential leak as a false positive parameters: - - description: Asset IDs to sync + - description: Credential ID + in: path + name: id + required: true + type: string + - description: Notes in: body name: request - required: true schema: - $ref: '#/definitions/internal_infra_http_handler.BulkSyncRequest' + $ref: '#/definitions/internal_infra_http_handler.CredentialStateChangeRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BulkSyncResponse' + $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Bulk sync repositories + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Mark credential as false positive tags: - - Assets - /assets/bulk/status: + - Credentials + /credentials/{id}/reactivate: post: - consumes: - - application/json - description: Updates the status of multiple assets at once + description: Mark a resolved credential as active again parameters: - - description: Bulk update data - in: body - name: request + - description: Credential ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.AssetBulkStatusRequest' + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetBulkStatusResponse' + $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Bulk update asset status + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Reactivate a resolved credential tags: - - Assets - /assets/repository: - post: - consumes: - - application/json - description: Creates a new repository asset with its extension data + - Credentials + /credentials/{id}/related: + get: + description: Get all credentials related to the same identity parameters: - - description: Repository asset data - in: body - name: request + - description: Credential ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CreateRepositoryAssetRequest' + type: string produces: - application/json responses: - "201": - description: Created - schema: - $ref: '#/definitions/internal_infra_http_handler.AssetWithRepositoryResponse' - "400": - description: Bad Request + "200": + description: OK schema: - additionalProperties: - type: string - type: object + items: + $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' + type: array "401": description: Unauthorized schema: - additionalProperties: - type: string - type: object - "409": - description: Conflict - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Create repository asset + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Get related credential leaks tags: - - Assets - /assets/stats: - get: - description: Returns aggregated statistics for assets + - Credentials + /credentials/{id}/resolve: + post: + consumes: + - application/json + description: Mark a credential leak as resolved parameters: - - description: Filter by types (comma-separated) - in: query - name: types + - description: Credential ID + in: path + name: id + required: true type: string + - description: Resolution notes + in: body + name: request + schema: + $ref: '#/definitions/internal_infra_http_handler.CredentialStateChangeRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AssetStatsResponse' - "401": - description: Unauthorized + $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialItem' + "400": + description: Bad Request schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Get asset statistics + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Mark credential as resolved tags: - - Assets - /attack-surface/stats: + - Credentials + /credentials/enums: get: - description: Returns attack surface statistics including total assets, exposed - services, critical exposures, and risk score + description: Get available credential types, source types, and other enums produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AttackSurfaceStatsResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get attack surface statistics + additionalProperties: true + type: object + summary: Get available enum values tags: - - Attack Surface - /audit-logs: + - Credentials + /credentials/identities: get: - description: Returns paginated audit logs for the current tenant + description: List credential leaks grouped by identity (username/email) parameters: - - description: Page number + - default: 1 + description: Page number in: query name: page type: integer - default: 20 - description: Items per page + description: Page size in: query - name: per_page + name: page_size type: integer - - description: Filter by actor ID - in: query - name: actor_id - type: string - - description: Filter by action - in: query - name: action - type: string - - description: Filter by resource type - in: query - name: resource_type - type: string - - description: Filter by resource ID - in: query - name: resource_id - type: string - - description: Filter by result - in: query - name: result - type: string - - description: Filter by severity - in: query - name: severity - type: string - - description: Filter since (RFC3339) - in: query - name: since - type: string - - description: Filter until (RFC3339) + - description: Filter by state (comma-separated) in: query - name: until + name: state type: string - - description: Search term + - description: Search in identifier in: query name: search type: string @@ -14968,85 +15077,32 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AuditLogListResponse' - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_internal_app.IdentityListResult' "401": description: Unauthorized schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: List audit logs + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: List credential leaks grouped by identity tags: - - Audit Logs - /audit-logs/{id}: + - Credentials + /credentials/identities/{identity}/exposures: get: - description: Returns a single audit log by ID + description: Get all credential exposures for a specific identity with pagination parameters: - - description: Audit Log ID + - description: Identity (username or email) in: path - name: id + name: identity required: true type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.AuditLogResponse' - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - schema: - additionalProperties: - type: string - type: object - "404": - description: Not Found - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Get audit log - tags: - - Audit Logs - /audit-logs/resource/{type}/{id}: - get: - description: Returns audit history for a specific resource - parameters: - - description: Resource type - in: path - name: type - required: true - type: string - - description: Resource ID - in: path - name: id - required: true - type: string - - description: Page number + - default: 1 + description: Page number in: query name: page type: integer - default: 20 - description: Items per page + description: Page size in: query - name: per_page + name: page_size type: integer produces: - application/json @@ -15054,466 +15110,490 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AuditLogListResponse' - "400": - description: Bad Request + $ref: '#/definitions/github_com_openctemio_api_internal_app.CredentialListResult' + "401": + description: Unauthorized schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Get resource history + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Get exposures for a specific identity (lazy load) tags: - - Audit Logs - /audit-logs/stats: - get: - description: Returns audit log statistics for the last 7 days + - Credentials + /credentials/import: + post: + consumes: + - application/json + description: Import credential leaks with deduplication support + parameters: + - description: Import request + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.CredentialImportRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.AuditStatsResponse' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_credential.ImportResult' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Get audit stats + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Import credential leaks tags: - - Audit Logs - /audit-logs/user/{id}: - get: - description: Returns audit logs for a specific user + - Credentials + /credentials/import/csv: + post: + consumes: + - multipart/form-data + description: Import credential leaks from CSV file parameters: - - description: User ID - in: path - name: id + - description: CSV file + in: formData + name: file required: true - type: string - - description: Page number + type: file + - description: Deduplication strategy in: query - name: page - type: integer - - default: 20 - description: Items per page + name: dedup_strategy + type: string + - description: Reactivate resolved credentials in: query - name: per_page - type: integer + name: reactivate_resolved + type: boolean produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.AuditLogListResponse' + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_credential.ImportResult' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Get user activity - tags: - - Audit Logs - /auth/forgot-password: - post: - consumes: - - application/json - description: Sends password reset email - parameters: - - description: Email address - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.ForgotPasswordRequest' - produces: - - application/json - responses: - "200": - description: OK + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "401": + description: Unauthorized schema: - additionalProperties: - type: string - type: object - summary: Forgot password + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Import credential leaks from CSV tags: - - Authentication - /auth/info: + - Credentials + /credentials/import/template: get: - description: Returns authentication provider information + description: Download CSV template for credential import produces: - - application/json + - text/csv responses: "200": - description: OK + description: CSV template schema: - $ref: '#/definitions/internal_infra_http_handler.AuthInfoResponse' - summary: Auth info + type: file + summary: Get CSV import template tags: - - Authentication - /auth/keycloak/info: + - Credentials + /credentials/stats: get: - description: Returns Keycloak server configuration URLs and realm info + description: Get statistics for credential leaks produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.KeycloakInfoResponse' - summary: Get Keycloak info + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Get credential leak statistics tags: - - Authentication - /auth/keycloak/token: - post: - description: Deprecated endpoint - returns redirect instruction to Keycloak - OAuth flow + - Credentials + /custom-tools: + get: + consumes: + - application/json + description: Get a paginated list of tenant's custom tools + parameters: + - description: Filter by category + in: query + name: category + type: string + - description: Filter by capabilities (comma-separated) + in: query + name: capabilities + type: string + - description: Filter by active status + in: query + name: is_active + type: boolean + - description: Search by name or description + in: query + name: search + type: string + - description: Filter by tags (comma-separated) + in: query + name: tags + type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - additionalProperties: - type: string - type: object - summary: Generate token (deprecated) + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ToolResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: List custom tools tags: - - Authentication - /auth/login: + - Custom Tools post: consumes: - application/json - description: Authenticates a user and returns refresh token and tenant list + description: Create a new tenant custom tool parameters: - - description: Login credentials + - description: Tool data in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.LoginRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateToolRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.LoginResponse' + $ref: '#/definitions/internal_infra_http_handler.ToolResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "409": + description: Conflict schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error schema: - additionalProperties: - type: string - type: object - summary: User login + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Create custom tool tags: - - Authentication - /auth/logout: - post: - description: Logs out the current user and invalidates the session + - Custom Tools + /custom-tools/{id}: + delete: + consumes: + - application/json + description: Delete a tenant custom tool + parameters: + - description: Tool ID + in: path + name: id + required: true + type: string produces: - application/json responses: - "200": - description: OK - schema: - additionalProperties: - type: string - type: object + "204": + description: No Content "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: User logout + summary: Delete custom tool tags: - - Authentication - /auth/oauth/{provider}/authorize: + - Custom Tools get: - description: Returns authorization URL for OAuth login with the specified provider + consumes: + - application/json + description: Get a single tenant custom tool by ID parameters: - - description: OAuth provider (google, github, gitlab) + - description: Tool ID in: path - name: provider + name: id required: true type: string - - description: Callback URL after authorization - in: query - name: redirect_uri - type: string - - description: Final redirect URL after login - in: query - name: final_redirect - type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.AuthorizeResponse' + $ref: '#/definitions/internal_infra_http_handler.ToolResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - summary: Get OAuth authorization URL + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Get custom tool tags: - - OAuth - /auth/oauth/{provider}/callback: - post: + - Custom Tools + put: consumes: - application/json - description: Handles the OAuth callback after user authorization + description: Update a tenant custom tool parameters: - - description: OAuth provider + - description: Tool ID in: path - name: provider + name: id required: true type: string - - description: Callback data + - description: Update data in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CallbackRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateToolRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CallbackResponse' + $ref: '#/definitions/internal_infra_http_handler.ToolResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - summary: OAuth callback handler + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Update custom tool tags: - - OAuth - /auth/oauth/providers: - get: - description: Returns list of available and configured OAuth providers + - Custom Tools + /custom-tools/{id}/activate: + post: + consumes: + - application/json + description: Activate a tenant custom tool + parameters: + - description: Tool ID + in: path + name: id + required: true + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ProvidersResponse' - summary: List OAuth providers + $ref: '#/definitions/internal_infra_http_handler.ToolResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Activate custom tool tags: - - OAuth - /auth/refresh: + - Custom Tools + /custom-tools/{id}/deactivate: post: consumes: - application/json - description: Refreshes access token and rotates refresh token + description: Deactivate a tenant custom tool parameters: - - description: Refresh token data - in: body - name: request + - description: Tool ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.RefreshTokenRequest' + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.RefreshTokenResponse' + $ref: '#/definitions/internal_infra_http_handler.ToolResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden schema: - additionalProperties: - type: string - type: object - summary: Refresh token + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Deactivate custom tool tags: - - Authentication - /auth/register: - post: - consumes: - - application/json - description: Registers a new user with email and password - parameters: - - description: Registration data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.RegisterRequest' + - Custom Tools + /dashboard/stats: + get: + description: Returns dashboard statistics for the current tenant produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.RegisterResponse' + $ref: '#/definitions/internal_infra_http_handler.DashboardStatsResponse' "400": description: Bad Request schema: additionalProperties: type: string type: object - "409": - description: Conflict + "401": + description: Unauthorized schema: additionalProperties: type: string type: object - summary: Register user - tags: - - Authentication - /auth/reset-password: - post: - consumes: - - application/json - description: Resets password using token - parameters: - - description: Reset token and new password - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.ResetPasswordRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: - type: string - type: object - "400": - description: Bad Request + "500": + description: Internal Server Error schema: additionalProperties: type: string type: object - summary: Reset password + security: + - BearerAuth: [] + summary: Get tenant dashboard stats tags: - - Authentication - /auth/token: - post: - consumes: - - application/json - description: Exchanges refresh token for tenant-scoped access token - parameters: - - description: Token exchange data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.ExchangeTokenRequest' + - Dashboard + /dashboard/stats/global: + get: + description: Returns dashboard statistics filtered by user's accessible tenants produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ExchangeTokenResponse' - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/internal_infra_http_handler.DashboardStatsResponse' "401": description: Unauthorized schema: additionalProperties: type: string type: object - summary: Exchange token - tags: - - Authentication - /auth/verify-email: - post: - consumes: - - application/json - description: Verifies user email with token - parameters: - - description: Verification token - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.VerifyEmailRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: - type: string - type: object - "400": - description: Bad Request + "500": + description: Internal Server Error schema: additionalProperties: type: string type: object - summary: Verify email + security: + - BearerAuth: [] + summary: Get global dashboard stats tags: - - Authentication - /commands: + - Dashboard + /exposures: get: consumes: - application/json - description: Get a paginated list of commands + description: Get a paginated list of exposure events parameters: - - description: Filter by agent ID + - description: Filter by asset ID in: query - name: agent_id + name: asset_id type: string - - description: Filter by type (scan, collect, health_check, config_update, cancel) + - collectionFormat: csv + description: Filter by event types in: query - name: type - type: string - - description: Filter by status (pending, running, completed, failed, canceled) + items: + type: string + name: event_type + type: array + - collectionFormat: csv + description: Filter by severities in: query - name: status - type: string - - description: Filter by priority (low, normal, high, critical) + items: + type: string + name: severity + type: array + - collectionFormat: csv + description: Filter by states in: query - name: priority + items: + type: string + name: state + type: array + - description: Search term + in: query + name: search type: string - default: 1 description: Page number @@ -15531,7 +15611,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_CommandResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ExposureResponse' "400": description: Bad Request schema: @@ -15542,47 +15622,51 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List commands + summary: List exposures tags: - - Commands + - Exposures post: consumes: - application/json - description: Create a new command to be executed by an agent + description: Create a new exposure event parameters: - - description: Command data + - description: Exposure data in: body - name: body + name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateCommandRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateExposureRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create command + summary: Create exposure tags: - - Commands - /commands/{id}: + - Exposures + /exposures/{id}: delete: consumes: - application/json - description: Delete a command + description: Delete an exposure event parameters: - - description: Command ID + - description: Exposure ID in: path name: id required: true @@ -15606,15 +15690,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete command + summary: Delete exposure tags: - - Commands + - Exposures get: consumes: - application/json - description: Get a single command by ID + description: Get a single exposure event by ID parameters: - - description: Command ID + - description: Exposure ID in: path name: id required: true @@ -15625,7 +15709,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' "400": description: Bad Request schema: @@ -15640,27 +15724,32 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get command + summary: Get exposure tags: - - Commands - /commands/{id}/cancel: + - Exposures + /exposures/{id}/accept: post: consumes: - application/json - description: Cancel a pending or running command + description: Accept an exposure as a known risk parameters: - - description: Command ID + - description: Exposure ID in: path name: id required: true type: string + - description: Accept reason + in: body + name: request + schema: + $ref: '#/definitions/internal_infra_http_handler.ChangeStateRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CommandResponse' + $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' "400": description: Bad Request schema: @@ -15675,150 +15764,92 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Cancel command + summary: Accept exposure tags: - - Commands - /components: - get: - description: Retrieves a paginated list of components for the current tenant + - Exposures + /exposures/{id}/false-positive: + post: + consumes: + - application/json + description: Mark an exposure as a false positive parameters: - - description: Filter by asset ID - in: query - name: asset_id - type: string - - description: Filter by name - in: query - name: name - type: string - - description: Filter by ecosystems (comma-separated) - in: query - name: ecosystems - type: string - - description: Filter by statuses (comma-separated) - in: query - name: statuses - type: string - - description: Filter by dependency types - in: query - name: dependency_types - type: string - - description: Filter by has vulnerabilities - in: query - name: has_vulnerabilities - type: boolean - - description: Filter by licenses (comma-separated) - in: query - name: licenses + - description: Exposure ID + in: path + name: id + required: true type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer + - description: Reason + in: body + name: request + schema: + $ref: '#/definitions/internal_infra_http_handler.ChangeStateRequest' produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List components + summary: Mark as false positive tags: - - Components - post: + - Exposures + /exposures/{id}/history: + get: consumes: - application/json - description: Creates a new component + description: Get state change history for an exposure parameters: - - description: Component data - in: body - name: request + - description: Exposure ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CreateComponentRequest' + type: string produces: - application/json responses: - "201": - description: Created - schema: - $ref: '#/definitions/internal_infra_http_handler.ComponentResponse' - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "409": - description: Conflict + "200": + description: OK schema: - additionalProperties: - type: string + additionalProperties: true type: object - security: - - BearerAuth: [] - summary: Create component - tags: - - Components - /components/{id}: - delete: - description: Deletes a component - parameters: - - description: Component ID - in: path - name: id - required: true - type: string - responses: - "204": - description: No Content "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete component + summary: Get exposure history tags: - - Components - get: - description: Retrieves a component by ID + - Exposures + /exposures/{id}/reactivate: + post: + consumes: + - application/json + description: Reactivate a resolved or accepted exposure parameters: - - description: Component ID + - description: Exposure ID in: path name: id required: true @@ -15829,179 +15860,124 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ComponentResponse' + $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get component + summary: Reactivate exposure tags: - - Components - put: + - Exposures + /exposures/{id}/resolve: + post: consumes: - application/json - description: Updates a component + description: Mark an exposure as resolved parameters: - - description: Component ID + - description: Exposure ID in: path name: id required: true type: string - - description: Component data + - description: Resolution reason in: body name: request - required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateComponentRequest' + $ref: '#/definitions/internal_infra_http_handler.ChangeStateRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ComponentResponse' + $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Update component - tags: - - Components - /components/ecosystems: - get: - description: Retrieves per-ecosystem statistics for the tenant - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_component.EcosystemStats' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get ecosystem statistics + summary: Resolve exposure tags: - - Components - /components/licenses: - get: - description: Retrieves license distribution statistics for the tenant - produces: + - Exposures + /exposures/ingest: + post: + consumes: - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_component.LicenseStats' - type: array - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Get license statistics - tags: - - Components - /components/stats: - get: - description: Retrieves aggregated component statistics for the tenant + description: Ingest multiple exposure events at once + parameters: + - description: Exposures to ingest + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkIngestRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_component.ComponentStats' + additionalProperties: true + type: object "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get component statistics + summary: Bulk ingest exposures tags: - - Components - /components/vulnerable: + - Exposures + /exposures/stats: get: - description: Retrieves components with vulnerability details for the tenant - parameters: - - default: 10 - description: Limit results - in: query - name: limit - type: integer + consumes: + - application/json + description: Get aggregated statistics for exposure events produces: - application/json responses: "200": description: OK schema: - items: - $ref: '#/definitions/github_com_openctemio_api_internal_domain_component.VulnerableComponent' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string + additionalProperties: true type: object "401": description: Unauthorized schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get vulnerable components + summary: Get exposure statistics tags: - - Components - /config/finding-sources: + - Exposures + /finding-sources: get: consumes: - application/json @@ -16088,7 +16064,7 @@ paths: summary: List finding sources tags: - Configuration - /config/finding-sources/{id}: + /finding-sources/{id}: get: consumes: - application/json @@ -16127,7 +16103,7 @@ paths: summary: Get a finding source by ID tags: - Configuration - /config/finding-sources/categories: + /finding-sources/categories: get: consumes: - application/json @@ -16189,7 +16165,7 @@ paths: summary: List finding source categories tags: - Configuration - /config/finding-sources/categories/{categoryId}: + /finding-sources/categories/{categoryId}: get: consumes: - application/json @@ -16228,7 +16204,7 @@ paths: summary: Get a category by ID tags: - Configuration - /config/finding-sources/code/{code}: + /finding-sources/code/{code}: get: consumes: - application/json @@ -16268,23 +16244,55 @@ paths: summary: Get a finding source by code tags: - Configuration - /credentials: + /findings: get: - consumes: - - application/json - description: List credentials with optional filters + description: Retrieves a paginated list of findings for the current tenant parameters: - - description: Filter by credential type + - description: Filter by asset ID in: query - name: credential_type + name: asset_id type: string - - description: Page number + - description: Filter by branch ID + in: query + name: branch_id + type: string + - description: Filter by component ID + in: query + name: component_id + type: string + - description: Filter by vulnerability ID + in: query + name: vulnerability_id + type: string + - description: Filter by severities (comma-separated) + in: query + name: severities + type: string + - description: Filter by statuses (comma-separated) + in: query + name: statuses + type: string + - description: Exclude statuses (comma-separated) + in: query + name: exclude_statuses + type: string + - description: Filter by sources + in: query + name: sources + type: string + - description: Filter by tool name + in: query + name: tool_name + type: string + - default: 1 + description: Page number in: query name: page type: integer - - description: Page size + - default: 20 + description: Items per page in: query - name: page_size + name: per_page type: integer produces: - application/json @@ -16292,94 +16300,93 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListCredentialsResponse' + additionalProperties: true + type: object "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + "401": + description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: List credentials + summary: List findings tags: - - Credentials + - Findings post: consumes: - application/json - description: Create a new credential for template sources + description: Creates a new finding for the current tenant parameters: - - description: Credential data + - description: Finding data in: body - name: body + name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateCredentialRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateFindingRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.CredentialResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Conflict - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + "401": + description: Unauthorized schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Create credential + summary: Create finding tags: - - Credentials - /credentials/{id}: + - Findings + /findings/{id}: delete: - consumes: - - application/json - description: Delete a credential + description: Deletes a finding parameters: - - description: Credential ID + - description: Finding ID in: path name: id required: true type: string - produces: - - application/json responses: "204": description: No Content "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Delete credential + summary: Delete finding tags: - - Credentials + - Findings get: - consumes: - - application/json - description: Get a single credential by ID (without sensitive data) + description: Retrieves a finding by ID parameters: - - description: Credential ID + - description: Finding ID in: path name: id required: true @@ -16390,316 +16397,308 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CredentialResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Get credential + summary: Get finding tags: - - Credentials - put: + - Findings + /findings/{id}/assign: + post: consumes: - application/json - description: Update credential metadata (not sensitive data) + description: Assigns a finding to a user parameters: - - description: Credential ID + - description: Finding ID in: path name: id required: true type: string - - description: Updated credential data + - description: Assignment data in: body - name: body + name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateCredentialRequest' + $ref: '#/definitions/internal_infra_http_handler.AssignFindingRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.CredentialResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Update credential + summary: Assign finding tags: - - Credentials - /custom-tools: - get: + - Findings + /findings/{id}/classify: + patch: consumes: - application/json - description: Get a paginated list of tenant's custom tools + description: Sets CVE, CWE, and CVSS classification for a finding parameters: - - description: Filter by category - in: query - name: category - type: string - - description: Filter by capabilities (comma-separated) - in: query - name: capabilities - type: string - - description: Filter by active status - in: query - name: is_active - type: boolean - - description: Search by name or description - in: query - name: search - type: string - - description: Filter by tags (comma-separated) - in: query - name: tags + - description: Finding ID + in: path + name: id + required: true type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer + - description: Classification data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.ClassifyFindingRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ToolResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + "404": + description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: List custom tools + summary: Classify finding tags: - - Custom Tools - post: - consumes: - - application/json - description: Create a new tenant custom tool + - Findings + /findings/{id}/dataflows: + get: + description: Returns the data flow traces (attack paths) for a finding parameters: - - description: Tool data - in: body - name: body + - description: Finding ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CreateToolRequest' + type: string produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ToolResponse' + $ref: '#/definitions/internal_infra_http_handler.DataFlowResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Conflict - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + "404": + description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Create custom tool + summary: Get finding data flows tags: - - Custom Tools - /custom-tools/{id}: - delete: + - Findings + /findings/{id}/severity: + patch: consumes: - application/json - description: Delete a tenant custom tool + description: Updates the severity level of a finding parameters: - - description: Tool ID + - description: Finding ID in: path name: id required: true type: string + - description: Severity data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.UpdateSeverityRequest' produces: - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Delete custom tool + summary: Update finding severity tags: - - Custom Tools - get: + - Findings + /findings/{id}/status: + patch: consumes: - application/json - description: Get a single tenant custom tool by ID + description: Updates the status of a finding parameters: - - description: Tool ID + - description: Finding ID in: path name: id required: true type: string + - description: Status data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.UpdateFindingStatusRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ToolResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Get custom tool + summary: Update finding status tags: - - Custom Tools + - Findings + /findings/{id}/tags: put: consumes: - application/json - description: Update a tenant custom tool + description: Sets the tags for a finding parameters: - - description: Tool ID + - description: Finding ID in: path name: id required: true type: string - - description: Update data + - description: Tags data in: body - name: body + name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateToolRequest' + $ref: '#/definitions/internal_infra_http_handler.SetTagsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ToolResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Update custom tool + summary: Set finding tags tags: - - Custom Tools - /custom-tools/{id}/activate: - post: + - Findings + /findings/{id}/triage: + patch: consumes: - application/json - description: Activate a tenant custom tool + description: Sets the triage status of a finding parameters: - - description: Tool ID + - description: Finding ID in: path name: id required: true type: string + - description: Triage data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.TriageFindingRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ToolResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Activate custom tool + summary: Triage finding tags: - - Custom Tools - /custom-tools/{id}/deactivate: + - Findings + /findings/{id}/unassign: post: - consumes: - - application/json - description: Deactivate a tenant custom tool + description: Removes assignment from a finding parameters: - - description: Tool ID + - description: Finding ID in: path name: id required: true @@ -16710,132 +16709,216 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ToolResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Deactivate custom tool + summary: Unassign finding tags: - - Custom Tools - /dashboard/stats: - get: - description: Returns dashboard statistics for the current tenant + - Findings + /findings/{id}/verify: + post: + consumes: + - application/json + description: Marks a resolved finding as verified + parameters: + - description: Finding ID + in: path + name: id + required: true + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.DashboardStatsResponse' + $ref: '#/definitions/internal_infra_http_handler.FindingResponse' "400": description: Bad Request schema: additionalProperties: type: string type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error + "404": + description: Not Found schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get tenant dashboard stats + summary: Verify finding fix tags: - - Dashboard - /dashboard/stats/global: - get: - description: Returns dashboard statistics filtered by user's accessible tenants + - Findings + /findings/bulk/assign: + post: + consumes: + - application/json + description: Assigns multiple findings to a user + parameters: + - description: Bulk assign data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkAssignRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.DashboardStatsResponse' - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error + $ref: '#/definitions/internal_infra_http_handler.BulkUpdateResponse' + "400": + description: Bad Request schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get global dashboard stats + summary: Bulk assign findings tags: - - Dashboard - /exposures: - get: + - Findings + /findings/bulk/status: + post: consumes: - application/json - description: Get a paginated list of exposure events + description: Updates the status of multiple findings parameters: - - description: Filter by asset ID - in: query - name: asset_id + - description: Bulk update data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkUpdateStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkUpdateResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Bulk update finding status + tags: + - Findings + /findings/remediation-groups: + get: + description: Groups the tenant's open findings by the fix that resolves them + (one patch → many findings). + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.remediationGroupsResponse' + security: + - BearerAuth: [] + summary: List remediation groups + tags: + - Findings + /findings/remediation-groups/{key}/resolve: + post: + description: Transitions every open finding sharing the fix to the requested + status in one action. + parameters: + - description: Remediation group key + in: path + name: key + required: true type: string - - collectionFormat: csv - description: Filter by event types + responses: + "200": + description: OK + schema: + additionalProperties: + type: integer + type: object + security: + - BearerAuth: [] + summary: Resolve a remediation group + tags: + - Findings + /findings/stats: + get: + description: |- + Returns aggregated statistics for findings. Optional asset_id query + parameter scopes the stats to a single asset (used by the Findings + page when filtered by `?assetId=…` so the severity cards match the + filtered table instead of showing global tenant counts). + parameters: + - description: Restrict stats to a single asset in: query - items: - type: string - name: event_type - type: array - - collectionFormat: csv - description: Filter by severities + name: asset_id + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.FindingStatsResponse' + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get finding statistics + tags: + - Findings + /groups: + get: + description: List all groups in the tenant with optional filtering + parameters: + - description: Filter by group type in: query - items: - type: string - name: severity - type: array - - collectionFormat: csv - description: Filter by states + name: type + type: string + - description: Filter by active status in: query - items: - type: string - name: state - type: array - - description: Search term + name: active + type: boolean + - description: Search by name or slug in: query name: search type: string - - default: 1 - description: Page number + - default: 20 + description: Limit results in: query - name: page + name: limit type: integer - - default: 20 - description: Items per page + - default: 0 + description: Offset for pagination in: query - name: per_page + name: offset type: integer produces: - application/json @@ -16843,38 +16926,28 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ExposureResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: List exposures + $ref: '#/definitions/internal_infra_http_handler.GroupListResponse' + summary: List groups tags: - - Exposures + - groups post: consumes: - application/json - description: Create a new exposure event + description: Create a new group for access control parameters: - - description: Exposure data + - description: Group details in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateExposureRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateGroupRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' + $ref: '#/definitions/internal_infra_http_handler.GroupResponse' "400": description: Bad Request schema: @@ -16883,56 +16956,38 @@ paths: description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + "403": + description: Forbidden schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Create exposure + summary: Create a new group tags: - - Exposures - /exposures/{id}: + - groups + /groups/{groupId}: delete: - consumes: - - application/json - description: Delete an exposure event + description: Delete a group parameters: - - description: Exposure ID + - description: Group ID in: path - name: id + name: groupId required: true type: string - produces: - - application/json responses: "204": description: No Content - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Delete exposure + summary: Delete a group tags: - - Exposures + - groups get: - consumes: - - application/json - description: Get a single exposure event by ID + description: Get a group's details parameters: - - description: Exposure ID + - description: Group ID in: path - name: id + name: groupId required: true type: string produces: @@ -16941,47 +16996,37 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + $ref: '#/definitions/internal_infra_http_handler.GroupResponse' "404": description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get exposure + summary: Get a group by ID tags: - - Exposures - /exposures/{id}/accept: - post: + - groups + put: consumes: - application/json - description: Accept an exposure as a known risk + description: Update a group's details parameters: - - description: Exposure ID + - description: Group ID in: path - name: id + name: groupId required: true type: string - - description: Accept reason + - description: Update details in: body name: request + required: true schema: - $ref: '#/definitions/internal_infra_http_handler.ChangeStateRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateGroupRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' + $ref: '#/definitions/internal_infra_http_handler.GroupResponse' "400": description: Bad Request schema: @@ -16990,74 +17035,55 @@ paths: description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Accept exposure + summary: Update a group tags: - - Exposures - /exposures/{id}/false-positive: - post: - consumes: - - application/json - description: Mark an exposure as a false positive + - groups + /groups/{groupId}/assets: + get: + description: List all assets that belong to this group parameters: - - description: Exposure ID + - description: Group ID in: path - name: id + name: groupId required: true type: string - - description: Reason - in: body - name: request - schema: - $ref: '#/definitions/internal_infra_http_handler.ChangeStateRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + items: + $ref: '#/definitions/internal_infra_http_handler.GroupOwnershipResponse' + type: array "404": description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Mark as false positive + summary: List assets assigned to a group tags: - - Exposures - /exposures/{id}/history: - get: + - groups + post: consumes: - application/json - description: Get state change history for an exposure + description: Assign an asset to the group with specified ownership type parameters: - - description: Exposure ID + - description: Group ID in: path - name: id + name: groupId required: true type: string + - description: Asset assignment details + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.AssignAssetRequest' produces: - application/json responses: - "200": - description: OK - schema: - additionalProperties: true - type: object + "204": + description: No Content "400": description: Bad Request schema: @@ -17066,33 +17092,59 @@ paths: description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + summary: Assign an asset to a group + tags: + - groups + /groups/{groupId}/assets/{assetId}: + delete: + description: Remove an asset ownership from the group + parameters: + - description: Group ID + in: path + name: groupId + required: true + type: string + - description: Asset ID + in: path + name: assetId + required: true + type: string + responses: + "204": + description: No Content + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get exposure history + summary: Remove an asset from a group tags: - - Exposures - /exposures/{id}/reactivate: - post: + - groups + put: consumes: - application/json - description: Reactivate a resolved or accepted exposure + description: Update the ownership type for an asset in a group parameters: - - description: Exposure ID + - description: Group ID in: path - name: id + name: groupId required: true type: string + - description: Asset ID + in: path + name: assetId + required: true + type: string + - description: Ownership details + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.UpdateAssetOwnershipRequest' produces: - application/json responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' + "204": + description: No Content "400": description: Bad Request schema: @@ -17101,38 +17153,57 @@ paths: description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + summary: Update asset ownership type + tags: + - groups + /groups/{groupId}/members: + get: + description: List all members of a group with user details + parameters: + - description: Group ID + in: path + name: groupId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/internal_infra_http_handler.GroupMemberWithUserResponse' + type: array + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Reactivate exposure + summary: List group members tags: - - Exposures - /exposures/{id}/resolve: + - groups post: consumes: - application/json - description: Mark an exposure as resolved + description: Add a user as a member of the group parameters: - - description: Exposure ID + - description: Group ID in: path - name: id + name: groupId required: true type: string - - description: Resolution reason + - description: Member details in: body name: request + required: true schema: - $ref: '#/definitions/internal_infra_http_handler.ChangeStateRequest' + $ref: '#/definitions/internal_infra_http_handler.AddGroupMemberRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.ExposureResponse' + $ref: '#/definitions/internal_infra_http_handler.GroupMemberResponse' "400": description: Bad Request schema: @@ -17141,165 +17212,303 @@ paths: description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + summary: Add a member to a group + tags: + - groups + /groups/{groupId}/members/{userId}: + delete: + description: Remove a user from the group + parameters: + - description: Group ID + in: path + name: groupId + required: true + type: string + - description: User ID + in: path + name: userId + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Resolve exposure + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Remove a member from a group tags: - - Exposures - /exposures/ingest: - post: + - groups + put: consumes: - application/json - description: Ingest multiple exposure events at once + description: Update the role of a group member parameters: - - description: Exposures to ingest + - description: Group ID + in: path + name: groupId + required: true + type: string + - description: User ID + in: path + name: userId + required: true + type: string + - description: Role update in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.BulkIngestRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateGroupMemberRoleRequest' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_infra_http_handler.GroupMemberResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Bulk ingest exposures + summary: Update a member's role tags: - - Exposures - /exposures/stats: + - groups + /groups/{groupId}/permission-sets: get: - consumes: - - application/json - description: Get aggregated statistics for exposure events + description: Get all permission sets assigned to the group with full details + parameters: + - description: Group ID + in: path + name: groupId + required: true + type: string produces: - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + items: + $ref: '#/definitions/internal_infra_http_handler.PermissionSetResponse' + type: array + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get exposure statistics + summary: List permission sets assigned to a group tags: - - Exposures - /findings: - get: - description: Retrieves a paginated list of findings for the current tenant + - groups + post: + consumes: + - application/json + description: Assign a permission set to the group parameters: - - description: Filter by asset ID - in: query - name: asset_id - type: string - - description: Filter by branch ID - in: query - name: branch_id - type: string - - description: Filter by component ID - in: query - name: component_id - type: string - - description: Filter by vulnerability ID - in: query - name: vulnerability_id + - description: Group ID + in: path + name: groupId + required: true type: string - - description: Filter by severities (comma-separated) - in: query - name: severities + - description: Permission set details + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.AssignPermissionSetRequest' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Assign a permission set to a group + tags: + - groups + /groups/{groupId}/permission-sets/{permissionSetId}: + delete: + description: Remove a permission set assignment from the group + parameters: + - description: Group ID + in: path + name: groupId + required: true type: string - - description: Filter by statuses (comma-separated) + - description: Permission Set ID + in: path + name: permissionSetId + required: true + type: string + responses: + "204": + description: No Content + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Remove a permission set from a group + tags: + - groups + /groups/sync: + post: + description: Trigger a manual sync of groups from external providers for the + current tenant + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Trigger manual group sync + tags: + - groups + /health: + get: + description: Returns the health status of the service (liveness probe) + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.HealthResponse' + summary: Health check + tags: + - Health + /integrations: + get: + consumes: + - application/json + description: Returns a paginated list of integrations for the current tenant + parameters: + - description: Filter by category + enum: + - scm + - security + - cloud + - ticketing + - notification in: query - name: statuses + name: category type: string - - description: Filter by sources + - description: Filter by provider in: query - name: sources + name: provider type: string - - description: Filter by tool name + - description: Filter by status + enum: + - pending + - connected + - disconnected + - error in: query - name: tool_name + name: status + type: string + - description: Search by name + in: query + name: search type: string - default: 1 description: Page number in: query + minimum: 1 name: page type: integer - default: 20 description: Items per page in: query + maximum: 100 + minimum: 1 name: per_page type: integer + - description: Sort field + enum: + - name + - category + - provider + - status + - created_at + - updated_at + in: query + name: sort + type: string + - description: Sort order + enum: + - asc + - desc + in: query + name: order + type: string produces: - application/json responses: "200": - description: OK + description: List of integrations schema: - additionalProperties: true + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_IntegrationResponse' + "401": + description: Unauthorized + schema: + additionalProperties: + type: string type: object - "400": - description: Bad Request + "403": + description: Forbidden - insufficient permissions schema: additionalProperties: type: string type: object - "401": - description: Unauthorized + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: List findings + summary: List integrations tags: - - Findings + - Integrations post: consumes: - application/json - description: Creates a new finding for the current tenant + description: Creates a new integration for connecting with external providers parameters: - - description: Finding data + - description: Integration details in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateFindingRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateIntegrationRequest' produces: - application/json responses: "201": - description: Created + description: Created integration schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' "400": - description: Bad Request + description: Bad request - validation error schema: additionalProperties: type: string @@ -17310,44 +17519,88 @@ paths: additionalProperties: type: string type: object + "403": + description: Forbidden - insufficient permissions + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict - integration with same name exists + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error + schema: + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Create finding + summary: Create integration tags: - - Findings - /findings/{id}: + - Integrations + /integrations/{id}: delete: - description: Deletes a finding + consumes: + - application/json + description: Permanently deletes an integration. This action cannot be undone. parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true type: string + produces: + - application/json responses: "204": - description: No Content + description: No content - successfully deleted "400": - description: Bad Request + description: Bad request - invalid ID + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden - insufficient permissions schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Delete finding + summary: Delete integration tags: - - Findings + - Integrations get: - description: Retrieves a finding by ID + consumes: + - application/json + description: Retrieves details of a specific integration by ID parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true @@ -17356,310 +17609,425 @@ paths: - application/json responses: "200": - description: OK + description: Integration details schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' "400": - description: Bad Request + description: Bad request - invalid ID + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden - insufficient permissions schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get finding + summary: Get integration tags: - - Findings - /findings/{id}/assign: - post: + - Integrations + put: consumes: - application/json - description: Assigns a finding to a user + description: Updates an existing integration. Only provided fields will be updated. parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true type: string - - description: Assignment data + - description: Fields to update in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.AssignFindingRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateIntegrationRequest' produces: - application/json responses: "200": - description: OK + description: Updated integration schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' "400": - description: Bad Request + description: Bad request - validation error + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden - insufficient permissions schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict - name already exists + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Assign finding + summary: Update integration tags: - - Findings - /findings/{id}/classify: - patch: + - Integrations + /integrations/{id}/disable: + post: consumes: - application/json - description: Sets CVE, CWE, and CVSS classification for a finding + description: Disables an active integration parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true type: string - - description: Classification data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.ClassifyFindingRequest' produces: - application/json responses: "200": - description: OK + description: Disabled integration schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' "400": - description: Bad Request + description: Bad request - invalid ID schema: additionalProperties: type: string type: object - "404": - description: Not Found + "401": + description: Unauthorized schema: additionalProperties: type: string type: object - security: - - BearerAuth: [] - summary: Classify finding - tags: - - Findings - /findings/{id}/dataflows: - get: - description: Returns the data flow traces (attack paths) for a finding - parameters: - - description: Finding ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.DataFlowResponse' - "400": - description: Bad Request + "403": + description: Forbidden schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get finding data flows + summary: Disable integration tags: - - Findings - /findings/{id}/severity: - patch: + - Integrations + /integrations/{id}/enable: + post: consumes: - application/json - description: Updates the severity level of a finding + description: Enables a disabled integration parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true type: string - - description: Severity data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateSeverityRequest' produces: - application/json responses: "200": - description: OK + description: Enabled integration schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' "400": - description: Bad Request + description: Bad request - invalid ID + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Update finding severity + summary: Enable integration tags: - - Findings - /findings/{id}/status: - patch: + - Integrations + /integrations/{id}/notification: + put: consumes: - application/json - description: Updates the status of a finding + description: Updates an existing notification integration (Slack, Teams, Telegram, + Webhook) parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true type: string - - description: Status data + - description: Updated notification integration details in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateFindingStatusRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateNotificationIntegrationRequest' produces: - application/json responses: "200": - description: OK + description: Updated notification integration schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationWithNotificationResponse' "400": - description: Bad Request + description: Bad request - validation error + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Update finding status + summary: Update notification integration tags: - - Findings - /findings/{id}/tags: - put: + - Integrations + /integrations/{id}/notification-events: + get: consumes: - application/json - description: Sets the tags for a finding + description: Retrieves notification events for a specific integration from the + audit trail parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true type: string - - description: Tags data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.SetTagsRequest' + - default: 50 + description: Maximum number of entries to return + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: 0 + description: Number of entries to skip + in: query + minimum: 0 + name: offset + type: integer produces: - application/json responses: "200": - description: OK + description: Notification events with pagination schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/github_com_openctemio_api_internal_app.GetNotificationEventsResult' "400": - description: Bad Request + description: Bad request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Set finding tags + summary: Get notification events tags: - - Findings - /findings/{id}/triage: - patch: + - Integrations + /integrations/{id}/repositories: + get: consumes: - application/json - description: Sets the triage status of a finding + description: Lists repositories accessible through an SCM integration parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true type: string - - description: Triage data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.TriageFindingRequest' + - description: Search by repository name + in: query + name: search + type: string + - default: 1 + description: Page number + in: query + minimum: 1 + name: page + type: integer + - default: 30 + description: Items per page + in: query + maximum: 100 + minimum: 1 + name: per_page + type: integer produces: - application/json responses: "200": - description: OK + description: List of repositories schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.ListSCMRepositoriesResponse' "400": - description: Bad Request + description: Bad request - invalid ID or not an SCM integration + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden - insufficient permissions schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Triage finding + summary: List repositories from SCM integration tags: - - Findings - /findings/{id}/unassign: + - Integrations + /integrations/{id}/sync: post: - description: Removes assignment from a finding + consumes: + - application/json + description: Triggers a sync for the integration parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true @@ -17668,33 +18036,52 @@ paths: - application/json responses: "200": - description: OK + description: Integration with updated sync status schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' "400": - description: Bad Request + description: Bad request - invalid ID + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden - insufficient permissions schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Unassign finding + summary: Sync integration tags: - - Findings - /findings/{id}/verify: + - Integrations + /integrations/{id}/test: post: consumes: - application/json - description: Marks a resolved finding as verified + description: Tests the integration by verifying credentials and connectivity parameters: - - description: Finding ID + - description: Integration ID + format: uuid in: path name: id required: true @@ -17703,274 +18090,157 @@ paths: - application/json responses: "200": - description: OK + description: Connection test result with updated status schema: - $ref: '#/definitions/internal_infra_http_handler.FindingResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' "400": - description: Bad Request + description: Bad request - invalid ID + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden - insufficient permissions schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Verify finding fix + summary: Test integration tags: - - Findings - /findings/bulk/assign: + - Integrations + /integrations/{id}/test-notification: post: consumes: - application/json - description: Assigns multiple findings to a user + description: Sends a test notification through the integration parameters: - - description: Bulk assign data - in: body - name: request + - description: Integration ID + format: uuid + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.BulkAssignRequest' + type: string produces: - application/json responses: "200": - description: OK + description: Test result schema: - $ref: '#/definitions/internal_infra_http_handler.BulkUpdateResponse' + additionalProperties: true + type: object "400": - description: Bad Request + description: Bad request schema: additionalProperties: type: string type: object - security: - - BearerAuth: [] - summary: Bulk assign findings - tags: - - Findings - /findings/bulk/status: - post: - consumes: - - application/json - description: Updates the status of multiple findings - parameters: - - description: Bulk update data - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.BulkUpdateStatusRequest' - produces: - - application/json - responses: - "200": - description: OK + "401": + description: Unauthorized schema: - $ref: '#/definitions/internal_infra_http_handler.BulkUpdateResponse' - "400": - description: Bad Request + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Bulk update finding status + summary: Test notification integration tags: - - Findings - /findings/stats: + - Integrations + /integrations/notifications: get: - description: Returns aggregated statistics for findings + consumes: + - application/json + description: Returns a list of notification integrations with their extensions produces: - application/json responses: "200": - description: OK + description: List of notification integrations schema: - $ref: '#/definitions/internal_infra_http_handler.FindingStatsResponse' + additionalProperties: + items: + $ref: '#/definitions/internal_infra_http_handler.IntegrationWithNotificationResponse' + type: array + type: object "401": description: Unauthorized schema: additionalProperties: type: string type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object "500": - description: Internal Server Error + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get finding statistics - tags: - - Findings - /health: - get: - description: Returns the health status of the service (liveness probe) - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.HealthResponse' - summary: Health check + summary: List notification integrations tags: - - Health - /ingest/check: + - Integrations post: consumes: - application/json - description: Check if fingerprints already exist for deduplication + description: Creates a new notification integration (Slack, Teams, Telegram, + Webhook) parameters: - - description: Fingerprints to check + - description: Notification integration details in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CheckFingerprintsRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.CheckFingerprintsResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - ApiKeyAuth: [] - summary: Check fingerprints - tags: - - Agent - /integrations: - get: - consumes: - - application/json - description: Returns a paginated list of integrations for the current tenant - parameters: - - description: Filter by category - enum: - - scm - - security - - cloud - - ticketing - - notification - in: query - name: category - type: string - - description: Filter by provider - in: query - name: provider - type: string - - description: Filter by status - enum: - - pending - - connected - - disconnected - - error - in: query - name: status - type: string - - description: Search by name - in: query - name: search - type: string - - default: 1 - description: Page number - in: query - minimum: 1 - name: page - type: integer - - default: 20 - description: Items per page - in: query - maximum: 100 - minimum: 1 - name: per_page - type: integer - - description: Sort field - enum: - - name - - category - - provider - - status - - created_at - - updated_at - in: query - name: sort - type: string - - description: Sort order - enum: - - asc - - desc - in: query - name: order - type: string - produces: - - application/json - responses: - "200": - description: List of integrations - schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_IntegrationResponse' - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - insufficient permissions - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal server error - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: List integrations - tags: - - Integrations - post: - consumes: - - application/json - description: Creates a new integration for connecting with external providers - parameters: - - description: Integration details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CreateIntegrationRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateNotificationIntegrationRequest' produces: - application/json responses: "201": - description: Created integration + description: Created notification integration schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' + $ref: '#/definitions/internal_infra_http_handler.IntegrationWithNotificationResponse' "400": description: Bad request - validation error schema: @@ -17984,7 +18254,7 @@ paths: type: string type: object "403": - description: Forbidden - insufficient permissions + description: Forbidden schema: additionalProperties: type: string @@ -18003,31 +18273,24 @@ paths: type: object security: - BearerAuth: [] - summary: Create integration + summary: Create notification integration tags: - Integrations - /integrations/{id}: - delete: + /integrations/scm: + get: consumes: - application/json - description: Permanently deletes an integration. This action cannot be undone. - parameters: - - description: Integration ID - format: uuid - in: path - name: id - required: true - type: string + description: Returns a list of SCM integrations with their extensions produces: - application/json responses: - "204": - description: No content - successfully deleted - "400": - description: Bad request - invalid ID + "200": + description: List of SCM integrations schema: additionalProperties: - type: string + items: + $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' + type: array type: object "401": description: Unauthorized @@ -18036,13 +18299,7 @@ paths: type: string type: object "403": - description: Forbidden - insufficient permissions - schema: - additionalProperties: - type: string - type: object - "404": - description: Not found + description: Forbidden schema: additionalProperties: type: string @@ -18055,29 +18312,31 @@ paths: type: object security: - BearerAuth: [] - summary: Delete integration + summary: List SCM integrations tags: - Integrations - get: + /integrations/test-credentials: + post: consumes: - application/json - description: Retrieves details of a specific integration by ID + description: Tests integration credentials by verifying connectivity without + persisting parameters: - - description: Integration ID - format: uuid - in: path - name: id + - description: Credentials to test + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.TestIntegrationCredentialsRequest' produces: - application/json responses: "200": - description: Integration details + description: Credentials test result schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' + $ref: '#/definitions/internal_infra_http_handler.TestIntegrationCredentialsResponse' "400": - description: Bad request - invalid ID + description: Bad request - validation error schema: additionalProperties: type: string @@ -18094,12 +18353,6 @@ paths: additionalProperties: type: string type: object - "404": - description: Not found - schema: - additionalProperties: - type: string - type: object "500": description: Internal server error schema: @@ -18108,518 +18361,820 @@ paths: type: object security: - BearerAuth: [] - summary: Get integration + summary: Test integration credentials without creating tags: - Integrations - put: - consumes: - - application/json - description: Updates an existing integration. Only provided fields will be updated. - parameters: - - description: Integration ID - format: uuid - in: path - name: id - required: true - type: string - - description: Fields to update - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateIntegrationRequest' + /me/assets: + get: + description: List all assets the current user can access through their group + memberships produces: - application/json responses: "200": - description: Updated integration - schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' - "400": - description: Bad request - validation error + description: OK schema: - additionalProperties: - type: string + additionalProperties: true type: object + summary: List current user's accessible assets + tags: + - assets + /me/bootstrap: + get: + description: 'Returns all initial data needed after login: permissions and modules.' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.BootstrapResponse' "401": description: Unauthorized schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - insufficient permissions - schema: - additionalProperties: - type: string - type: object - "404": - description: Not found + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error schema: - additionalProperties: - type: string - type: object - "409": - description: Conflict - name already exists - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal server error - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update integration + summary: Bootstrap initial data tags: - - Integrations - /integrations/{id}/disable: - post: - consumes: - - application/json - description: Disables an active integration + - Bootstrap + /me/event-types: + get: + description: Returns the notification event types available to the current tenant, + filtered by the tenant's enabled modules, with the subset enabled by default. parameters: - - description: Integration ID - format: uuid - in: path - name: id - required: true + - description: ETag from previous response + in: header + name: If-None-Match type: string produces: - application/json responses: "200": - description: Disabled integration + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' - "400": - description: Bad request - invalid ID + $ref: '#/definitions/internal_infra_http_handler.TenantEventTypesResponse' + "304": + description: Not Modified - catalog unchanged schema: - additionalProperties: - type: string - type: object + type: string "401": description: Unauthorized schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - schema: - additionalProperties: - type: string - type: object - "404": - description: Not found - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": - description: Internal server error + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Disable integration + summary: Get tenant notification event types tags: - - Integrations - /integrations/{id}/enable: - post: - consumes: - - application/json - description: Enables a disabled integration - parameters: - - description: Integration ID - format: uuid - in: path - name: id - required: true - type: string + - Modules + /me/groups: + get: + description: List all groups the current user belongs to produces: - application/json responses: "200": - description: Enabled integration + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' - "400": - description: Bad request - invalid ID + items: + $ref: '#/definitions/internal_infra_http_handler.GroupWithRoleResponse' + type: array + summary: List current user's groups + tags: + - groups + /me/modules: + get: + description: Returns the modules available to the current tenant based on their + subscription. + produces: + - application/json + responses: + "200": + description: OK schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/internal_infra_http_handler.TenantModulesResponse' "401": description: Unauthorized schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error schema: - additionalProperties: - type: string - type: object - "404": - description: Not found + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Get tenant modules + tags: + - Modules + /me/permissions: + get: + description: Get all effective permissions for the current user based on their + group memberships + produces: + - application/json + responses: + "200": + description: OK schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/internal_infra_http_handler.EffectivePermissionsResponse' + summary: Get effective permissions for current user + tags: + - permissions + /notification-outbox: + get: + consumes: + - application/json + description: List notification outbox entries for the current tenant with filtering + and pagination + parameters: + - description: Filter by status (pending, processing, completed, failed, dead) + in: query + name: status + type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Page size + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_pagination.Result-internal_infra_http_handler_OutboxEntryResponse' "500": - description: Internal server error + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' security: - BearerAuth: [] - summary: Enable integration + summary: List notification outbox entries tags: - - Integrations - /integrations/{id}/notification: - put: + - notification-outbox + /notification-outbox/{id}: + delete: consumes: - application/json - description: Updates an existing notification integration (Slack, Teams, Telegram, - Webhook) + description: Delete a specific outbox entry (must belong to current tenant) parameters: - - description: Integration ID - format: uuid + - description: Outbox entry ID in: path name: id required: true type: string - - description: Updated notification integration details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateNotificationIntegrationRequest' produces: - application/json responses: - "200": - description: Updated notification integration - schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationWithNotificationResponse' - "400": - description: Bad request - validation error - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - schema: - additionalProperties: - type: string - type: object + "204": + description: No Content "404": - description: Not found + description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' "500": - description: Internal server error + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' security: - BearerAuth: [] - summary: Update notification integration + summary: Delete outbox entry tags: - - Integrations - /integrations/{id}/notification-events: + - notification-outbox get: consumes: - application/json - description: Retrieves notification events for a specific integration from the - audit trail + description: Get a specific outbox entry by ID (must belong to current tenant) parameters: - - description: Integration ID - format: uuid + - description: Outbox entry ID in: path name: id required: true type: string - - default: 50 - description: Maximum number of entries to return - in: query - maximum: 100 - minimum: 1 - name: limit - type: integer - - default: 0 - description: Number of entries to skip - in: query - minimum: 0 - name: offset - type: integer produces: - application/json responses: "200": - description: Notification events with pagination - schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.GetNotificationEventsResult' - "400": - description: Bad request - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden + description: OK schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/internal_infra_http_handler.OutboxEntryResponse' "404": - description: Not found + description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' "500": - description: Internal server error + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' security: - BearerAuth: [] - summary: Get notification events + summary: Get notification outbox entry tags: - - Integrations - /integrations/{id}/repositories: - get: + - notification-outbox + /notification-outbox/{id}/retry: + post: consumes: - application/json - description: Lists repositories accessible through an SCM integration + description: Reset a failed/dead outbox entry to pending for retry (must belong + to current tenant) parameters: - - description: Integration ID - format: uuid + - description: Outbox entry ID in: path name: id required: true type: string - - description: Search by repository name - in: query - name: search - type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.OutboxEntryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + security: + - BearerAuth: [] + summary: Retry failed outbox entry + tags: + - notification-outbox + /notification-outbox/stats: + get: + consumes: + - application/json + description: Get counts of outbox entries by status for the current tenant + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.OutboxStatsResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + security: + - BearerAuth: [] + summary: Get notification outbox statistics + tags: + - notification-outbox + /notifications: + get: + consumes: + - application/json + description: List notifications for the current user with filtering and pagination + parameters: - default: 1 description: Page number in: query - minimum: 1 name: page type: integer - - default: 30 + - default: 20 description: Items per page in: query - maximum: 100 - minimum: 1 name: per_page type: integer + - description: Filter by severity + in: query + name: severity + type: string + - description: Filter by notification type + in: query + name: type + type: string + - description: Filter by read status (true/false) + in: query + name: is_read + type: string produces: - application/json responses: "200": - description: List of repositories - schema: - $ref: '#/definitions/internal_infra_http_handler.ListSCMRepositoriesResponse' - "400": - description: Bad request - invalid ID or not an SCM integration + description: OK schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_pagination.Result-internal_infra_http_handler_NotificationResponse' "401": description: Unauthorized schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - insufficient permissions - schema: - additionalProperties: - type: string - type: object - "404": - description: Not found - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' "500": - description: Internal server error + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' security: - BearerAuth: [] - summary: List repositories from SCM integration + summary: List notifications tags: - - Integrations - /integrations/{id}/send: - post: + - notifications + /notifications/{id}/read: + patch: consumes: - application/json - description: Sends a notification through the specified integration + description: Mark a single notification as read for the current user parameters: - - description: Integration ID - format: uuid + - description: Notification ID in: path name: id required: true type: string - - description: Notification content - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.SendNotificationRequest' produces: - application/json responses: - "200": - description: Send result - schema: - additionalProperties: true - type: object + "204": + description: No Content "400": - description: Bad request + description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' "401": description: Unauthorized schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' "404": - description: Not found + description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' "500": - description: Internal server error + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' security: - BearerAuth: [] - summary: Send notification + summary: Mark notification as read tags: - - Integrations - /integrations/{id}/sync: - post: + - notifications + /notifications/preferences: + get: consumes: - application/json - description: Triggers a sync for the integration + description: Get notification preferences for the current user + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.PreferencesResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + security: + - BearerAuth: [] + summary: Get notification preferences + tags: + - notifications + put: + consumes: + - application/json + description: Update notification preferences for the current user parameters: - - description: Integration ID - format: uuid - in: path - name: id + - description: Updated preferences + in: body + name: body required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.NotificationPreferencesRequest' produces: - application/json responses: "200": - description: Integration with updated sync status + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' + $ref: '#/definitions/internal_infra_http_handler.PreferencesResponse' "400": - description: Bad request - invalid ID + description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' "401": description: Unauthorized schema: - additionalProperties: - type: string + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + security: + - BearerAuth: [] + summary: Update notification preferences + tags: + - notifications + /notifications/read-all: + post: + consumes: + - application/json + description: Mark all notifications as read for the current user in the current + tenant + produces: + - application/json + responses: + "204": + description: No Content + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + security: + - BearerAuth: [] + summary: Mark all notifications as read + tags: + - notifications + /notifications/unread-count: + get: + consumes: + - application/json + description: Get the number of unread notifications for the current user + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.UnreadCountResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + security: + - BearerAuth: [] + summary: Get unread notification count + tags: + - notifications + /permission-sets: + get: + description: List all permission sets for the tenant + parameters: + - description: Include system permission sets + in: query + name: include_system + type: boolean + - description: Filter by type + in: query + name: type + type: string + - description: Search by name + in: query + name: search + type: string + - default: 20 + description: Limit results + in: query + name: limit + type: integer + - default: 0 + description: Offset for pagination + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.PermissionSetListResponse' + summary: List permission sets + tags: + - permission-sets + post: + consumes: + - application/json + description: Create a new permission set for access control + parameters: + - description: Permission set details + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.CreatePermissionSetRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_infra_http_handler.PermissionSetResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Create a new permission set + tags: + - permission-sets + /permission-sets/{id}: + delete: + description: Delete a permission set + parameters: + - description: Permission set ID + in: path + name: id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Delete a permission set + tags: + - permission-sets + get: + description: Get detailed information about a permission set + parameters: + - description: Permission set ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.PermissionSetWithItemsResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Get a permission set by ID + tags: + - permission-sets + put: + consumes: + - application/json + description: Update an existing permission set + parameters: + - description: Permission set ID + in: path + name: id + required: true + type: string + - description: Update details + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.UpdatePermissionSetRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.PermissionSetResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Update a permission set + tags: + - permission-sets + /permission-sets/{id}/permissions: + post: + consumes: + - application/json + description: Add a permission to a permission set + parameters: + - description: Permission set ID + in: path + name: id + required: true + type: string + - description: Permission details + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.AddPermissionRequest' + produces: + - application/json + responses: + "201": + description: Created + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Add a permission to a set + tags: + - permission-sets + /permission-sets/{id}/permissions/{permissionId}: + delete: + description: Remove a permission from a permission set + parameters: + - description: Permission set ID + in: path + name: id + required: true + type: string + - description: Permission ID + in: path + name: permissionId + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + summary: Remove a permission from a set + tags: + - permission-sets + /permission-sets/system: + get: + description: List all system-defined permission sets + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/internal_infra_http_handler.PermissionSetResponse' + type: array + summary: List system permission sets + tags: + - permission-sets + /ready: + get: + description: Checks all dependencies and returns 503 if any are unhealthy + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.ReadyResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/internal_infra_http_handler.ReadyResponse' + summary: Readiness check + tags: + - Health + /repositories/{repository_id}/branches: + get: + description: Retrieves all branches for a repository + parameters: + - description: Repository ID + in: path + name: repository_id + required: true + type: string + - description: Filter by name + in: query + name: name + type: string + - description: Filter by types (comma-separated) + in: query + name: types + type: string + - description: Filter by default branch + in: query + name: is_default + type: boolean + - description: Filter by scan status + in: query + name: scan_status + type: string + - description: Sort field + in: query + name: sort + type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true type: object - "403": - description: Forbidden - insufficient permissions + "400": + description: Bad Request schema: additionalProperties: type: string type: object - "404": - description: Not found + "401": + description: Unauthorized schema: additionalProperties: type: string type: object - "500": - description: Internal server error + "404": + description: Not Found schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Sync integration + summary: List branches tags: - - Integrations - /integrations/{id}/test: + - Branches post: consumes: - application/json - description: Tests the integration by verifying credentials and connectivity + description: Creates a new branch for a repository parameters: - - description: Integration ID - format: uuid + - description: Repository ID in: path - name: id + name: repository_id required: true type: string + - description: Branch data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.CreateBranchRequest' produces: - application/json responses: - "200": - description: Connection test result with updated status + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' + $ref: '#/definitions/internal_infra_http_handler.BranchResponse' "400": - description: Bad request - invalid ID + description: Bad Request schema: additionalProperties: type: string @@ -18630,310 +19185,249 @@ paths: additionalProperties: type: string type: object - "403": - description: Forbidden - insufficient permissions - schema: - additionalProperties: - type: string - type: object - "404": - description: Not found - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal server error + "409": + description: Conflict schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Test integration + summary: Create branch tags: - - Integrations - /integrations/{id}/test-notification: - post: - consumes: - - application/json - description: Sends a test notification through the integration + - Branches + /repositories/{repository_id}/branches/{id}: + delete: + description: Deletes a branch parameters: - - description: Integration ID - format: uuid + - description: Repository ID + in: path + name: repository_id + required: true + type: string + - description: Branch ID in: path name: id required: true type: string - produces: - - application/json responses: - "200": - description: Test result - schema: - additionalProperties: true - type: object + "204": + description: No Content "400": - description: Bad request - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden + description: Bad Request schema: additionalProperties: type: string type: object "404": - description: Not found - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal server error + description: Not Found schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Test notification integration + summary: Delete branch tags: - - Integrations - /integrations/notifications: + - Branches get: - consumes: - - application/json - description: Returns a list of notification integrations with their extensions + description: Retrieves a branch by ID + parameters: + - description: Repository ID + in: path + name: repository_id + required: true + type: string + - description: Branch ID + in: path + name: id + required: true + type: string produces: - application/json responses: "200": - description: List of notification integrations - schema: - additionalProperties: - items: - $ref: '#/definitions/internal_infra_http_handler.IntegrationWithNotificationResponse' - type: array - type: object - "401": - description: Unauthorized + description: OK schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden + $ref: '#/definitions/internal_infra_http_handler.BranchResponse' + "400": + description: Bad Request schema: additionalProperties: type: string type: object - "500": - description: Internal server error + "404": + description: Not Found schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: List notification integrations + summary: Get branch tags: - - Integrations - post: + - Branches + put: consumes: - application/json - description: Creates a new notification integration (Slack, Teams, Telegram, - Webhook) + description: Updates a branch parameters: - - description: Notification integration details + - description: Repository ID + in: path + name: repository_id + required: true + type: string + - description: Branch ID + in: path + name: id + required: true + type: string + - description: Branch data in: body name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateNotificationIntegrationRequest' - produces: - - application/json - responses: - "201": - description: Created notification integration - schema: - $ref: '#/definitions/internal_infra_http_handler.IntegrationWithNotificationResponse' - "400": - description: Bad request - validation error - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - schema: - additionalProperties: - type: string - type: object - "409": - description: Conflict - integration with same name exists - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal server error - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Create notification integration - tags: - - Integrations - /integrations/scm: - get: - consumes: - - application/json - description: Returns a list of SCM integrations with their extensions + $ref: '#/definitions/internal_infra_http_handler.UpdateBranchRequest' produces: - application/json responses: "200": - description: List of SCM integrations - schema: - additionalProperties: - items: - $ref: '#/definitions/internal_infra_http_handler.IntegrationResponse' - type: array - type: object - "401": - description: Unauthorized + description: OK schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden + $ref: '#/definitions/internal_infra_http_handler.BranchResponse' + "400": + description: Bad Request schema: additionalProperties: type: string type: object - "500": - description: Internal server error + "404": + description: Not Found schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: List SCM integrations + summary: Update branch tags: - - Integrations - /integrations/test-credentials: - post: - consumes: - - application/json - description: Tests integration credentials by verifying connectivity without - persisting - parameters: - - description: Credentials to test - in: body - name: request + - Branches + /repositories/{repository_id}/branches/{id}/default: + put: + description: Sets a branch as the default for a repository + parameters: + - description: Repository ID + in: path + name: repository_id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.TestIntegrationCredentialsRequest' + type: string + - description: Branch ID + in: path + name: id + required: true + type: string produces: - application/json responses: "200": - description: Credentials test result + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.TestIntegrationCredentialsResponse' + $ref: '#/definitions/internal_infra_http_handler.BranchResponse' "400": - description: Bad request - validation error - schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "403": - description: Forbidden - insufficient permissions + description: Bad Request schema: additionalProperties: type: string type: object - "500": - description: Internal server error + "404": + description: Not Found schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Test integration credentials without creating + summary: Set default branch tags: - - Integrations - /me/bootstrap: + - Branches + /repositories/{repository_id}/branches/default: get: - description: 'Returns all initial data needed after login: permissions, subscription, - and modules.' + description: Gets the default branch for a repository + parameters: + - description: Repository ID + in: path + name: repository_id + required: true + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BootstrapResponse' - "401": - description: Unauthorized + $ref: '#/definitions/internal_infra_http_handler.BranchResponse' + "400": + description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error + additionalProperties: + type: string + type: object + "404": + description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + additionalProperties: + type: string + type: object security: - BearerAuth: [] - summary: Bootstrap initial data + summary: Get default branch tags: - - Bootstrap - /me/event-types: + - Branches + /scan-profiles: get: - description: Returns the notification event types available to the current - tenant, filtered by the tenant's enabled modules, with the subset enabled - by default. + consumes: + - application/json + description: Get a paginated list of scan profiles for the current tenant parameters: - - description: ETag from previous response - in: header - name: If-None-Match + - description: Filter by default status + in: query + name: is_default + type: boolean + - description: Filter by system status + in: query + name: is_system + type: boolean + - description: 'Include system profiles in results (default: true)' + in: query + name: include_system + type: boolean + - description: Filter by tags (comma-separated) + in: query + name: tags + type: string + - description: Search by name or description + in: query + name: search type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.TenantEventTypesResponse' - "304": - description: Not Modified - catalog unchanged - "401": - description: Unauthorized + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanProfileResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -18942,30 +19436,33 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get tenant notification event types + summary: List scan profiles tags: - - Modules - /me/permissions: - get: - description: |- - Returns the permissions for the authenticated user in the current tenant. - Supports ETag-based caching: send If-None-Match header to check for changes. + - Scan Profiles + post: + consumes: + - application/json + description: Create a new scan profile with tool configurations parameters: - - description: ETag from previous response - in: header - name: If-None-Match - type: string + - description: Scan profile data + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.CreateScanProfileRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.PermissionsResponse' - "304": - description: Not Modified - permissions unchanged - "401": - description: Unauthorized + $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "409": + description: Conflict schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -18974,65 +19471,52 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get current user permissions - tags: - - Permissions - /modules: - get: - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/internal_infra_http_handler.LicensingModuleResponse' - type: array - summary: List modules + summary: Create scan profile tags: - - Licensing - /notification-outbox: - get: + - Scan Profiles + /scan-profiles/{id}: + delete: consumes: - application/json - description: List notification outbox entries for the current tenant with filtering - and pagination + description: Delete a scan profile (system profiles cannot be deleted) parameters: - - description: Filter by status (pending, processing, completed, failed, dead) - in: query - name: status + - description: Scan Profile ID + in: path + name: id + required: true type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Page size - in: query - name: page_size - type: integer produces: - application/json responses: - "200": - description: OK + "204": + description: No Content + "400": + description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_pagination.Result-internal_infra_http_handler_OutboxEntryResponse' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List notification outbox entries + summary: Delete scan profile tags: - - notification-outbox - /notification-outbox/{id}: - delete: + - Scan Profiles + get: consumes: - application/json - description: Delete a specific outbox entry (must belong to current tenant) + description: Get a single scan profile by ID parameters: - - description: Outbox entry ID + - description: Scan Profile ID in: path name: id required: true @@ -19040,163 +19524,146 @@ paths: produces: - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete outbox entry + summary: Get scan profile tags: - - notification-outbox - get: + - Scan Profiles + put: consumes: - application/json - description: Get a specific outbox entry by ID (must belong to current tenant) + description: Update an existing scan profile parameters: - - description: Outbox entry ID + - description: Scan Profile ID in: path name: id required: true type: string + - description: Update data + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.UpdateScanProfileRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.OutboxEntryResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get notification outbox entry + summary: Update scan profile tags: - - notification-outbox - /notification-outbox/{id}/retry: + - Scan Profiles + /scan-profiles/{id}/clone: post: consumes: - application/json - description: Reset a failed/dead outbox entry to pending for retry (must belong - to current tenant) + description: Create a copy of an existing scan profile with a new name parameters: - - description: Outbox entry ID + - description: Scan Profile ID to clone in: path name: id required: true type: string + - description: Clone data + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.CloneScanProfileRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.OutboxEntryResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Retry failed outbox entry + summary: Clone scan profile tags: - - notification-outbox - /notification-outbox/stats: - get: + - Scan Profiles + /scan-profiles/{id}/evaluate-quality-gate: + post: consumes: - application/json - description: Get counts of outbox entries by status for the current tenant - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.OutboxStatsResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Response' - security: - - BearerAuth: [] - summary: Get notification outbox statistics - tags: - - notification-outbox - /plans: - get: - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/internal_infra_http_handler.PlanResponse' - type: array - summary: List public plans - tags: - - Licensing - /plans/{id}: - get: + description: Evaluate finding counts against a scan profile's quality gate parameters: - - description: Plan ID or slug + - description: Scan Profile ID in: path name: id required: true type: string - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.PlanResponse' - summary: Get plan - tags: - - Licensing - /platform-agent/heartbeat: - post: - consumes: - - application/json - description: Record a heartbeat from a platform agent - parameters: - - description: Heartbeat data + - description: Finding counts in: body name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.HeartbeatRequest' + $ref: '#/definitions/internal_infra_http_handler.EvaluateQualityGateRequest' produces: - application/json responses: "200": description: OK schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/internal_infra_http_handler.QualityGateResultResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -19204,44 +19671,38 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Platform agent heartbeat + - BearerAuth: [] + summary: Evaluate quality gate tags: - - Platform Agent API - /platform-agent/jobs/{id}/status: - post: + - Scan Profiles + /scan-profiles/{id}/quality-gate: + put: consumes: - application/json - description: Platform agent updates the status of a job it's executing + description: Update the quality gate configuration for a scan profile parameters: - - description: Job ID + - description: Scan Profile ID in: path name: id required: true type: string - - description: Status update + - description: Quality gate configuration in: body name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateJobStatusRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateQualityGateRequest' produces: - application/json responses: "200": description: OK schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "403": description: Forbidden schema: @@ -19255,39 +19716,34 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Update job status + - BearerAuth: [] + summary: Update quality gate tags: - - Platform Agent API - /platform-agent/jobs/claim: + - Scan Profiles + /scan-profiles/{id}/set-default: post: consumes: - application/json - description: Platform agent claims the next available job matching its capabilities + description: Set a scan profile as the default for the tenant parameters: - - description: Agent capabilities - in: body - name: body + - description: Scan Profile ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.ClaimJobRequest' + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ClaimJobResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -19295,57 +19751,50 @@ paths: schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - - ApiKeyAuth: [] - summary: Claim next job + - BearerAuth: [] + summary: Set default scan profile tags: - - Platform Agent API - /platform-agents/register: - post: + - Scan Profiles + /scan-profiles/default: + get: consumes: - application/json - description: Self-register a platform agent using a bootstrap token (no auth - required) - parameters: - - description: Registration data - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.RegisterAgentRequest' + description: Get the default scan profile for the current tenant produces: - application/json responses: - "201": - description: Created - schema: - $ref: '#/definitions/internal_infra_http_handler.RegisterAgentResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Invalid or expired bootstrap token + "200": + description: OK schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Token constraints not met + $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - summary: Register platform agent + security: + - BearerAuth: [] + summary: Get default scan profile tags: - - Platform Agents - /platform-jobs: + - Scan Profiles + /scan-sessions: get: consumes: - application/json - description: Get a paginated list of platform jobs for the current tenant + description: Get a paginated list of scan sessions for the tenant parameters: - - description: Filter by status (pending, acknowledged, running, completed, - failed, expired) + - description: Filter by scanner name + in: query + name: scanner_name + type: string + - description: Filter by asset type + in: query + name: asset_type + type: string + - description: Filter by status in: query name: status type: string @@ -19365,52 +19814,42 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_PlatformJobResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanSessionResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List platform jobs + summary: List scan sessions tags: - - Platform Jobs - post: + - Scan Sessions + /scan-sessions/{id}: + delete: consumes: - application/json - description: Submit a new job to be executed by a platform agent + description: Delete a scan session parameters: - - description: Job data - in: body - name: body + - description: Scan session ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.SubmitJobRequest' + type: string produces: - application/json responses: - "201": - description: Created - schema: - $ref: '#/definitions/internal_infra_http_handler.SubmitJobResponse' + "204": + description: No Content "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "429": - description: Queue limit reached + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -19419,16 +19858,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Submit platform job + summary: Delete scan session tags: - - Platform Jobs - /platform-jobs/{id}: + - Scan Sessions get: consumes: - application/json - description: Get the current status of a platform job + description: Get a single scan session by ID parameters: - - description: Job ID + - description: Scan session ID in: path name: id required: true @@ -19439,15 +19877,11 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.SubmitJobResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanSessionResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: @@ -19458,19 +19892,18 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get job status + summary: Get scan session tags: - - Platform Jobs - /platform-jobs/{id}/cancel: - post: + - Scan Sessions + /scan-sessions/stats: + get: consumes: - application/json - description: Cancel a pending or running platform job + description: Get aggregated scan session statistics parameters: - - description: Job ID - in: path - name: id - required: true + - description: Start date (RFC3339 format) + in: query + name: since type: string produces: - application/json @@ -19478,73 +19911,42 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.PlatformJobResponse' + additionalProperties: true + type: object "400": description: Bad Request schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Cancel platform job - tags: - - Platform Jobs - /ready: - get: - description: Checks all dependencies and returns 503 if any are unhealthy - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.ReadyResponse' - "503": - description: Service Unavailable + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error schema: - $ref: '#/definitions/internal_infra_http_handler.ReadyResponse' - summary: Readiness check + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Get scan session statistics tags: - - Health - /repositories/{repository_id}/branches: + - Scan Sessions + /scanner-templates: get: - description: Retrieves all branches for a repository + consumes: + - application/json + description: Get a paginated list of scanner templates for the current tenant parameters: - - description: Repository ID - in: path - name: repository_id - required: true - type: string - - description: Filter by name + - description: Filter by template type (nuclei, semgrep, gitleaks) in: query - name: name + name: template_type type: string - - description: Filter by types (comma-separated) + - description: Filter by status (active, pending_review, deprecated, revoked) in: query - name: types + name: status type: string - - description: Filter by default branch - in: query - name: is_default - type: boolean - - description: Filter by scan status + - description: Filter by tags (comma-separated) in: query - name: scan_status + name: tags type: string - - description: Sort field + - description: Search by name or description in: query - name: sort + name: search type: string - default: 1 description: Page number @@ -19562,120 +19964,102 @@ paths: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScannerTemplateResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object - "404": - description: Not Found + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List branches + summary: List scanner templates tags: - - Branches + - Scanner Templates post: consumes: - application/json - description: Creates a new branch for a repository + description: Create a new custom scanner template (Nuclei, Semgrep, or Gitleaks) parameters: - - description: Repository ID - in: path - name: repository_id - required: true - type: string - - description: Branch data + - description: Template data in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateBranchRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateScannerTemplateRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.BranchResponse' + $ref: '#/definitions/internal_infra_http_handler.ScannerTemplateResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "401": - description: Unauthorized - schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "409": description: Conflict schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create branch + summary: Create scanner template tags: - - Branches - /repositories/{repository_id}/branches/{id}: + - Scanner Templates + /scanner-templates/{id}: delete: - description: Deletes a branch + consumes: + - application/json + description: Delete a scanner template parameters: - - description: Repository ID - in: path - name: repository_id - required: true - type: string - - description: Branch ID + - description: Template ID in: path name: id required: true type: string + produces: + - application/json responses: "204": description: No Content "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete branch + summary: Delete scanner template tags: - - Branches + - Scanner Templates get: - description: Retrieves a branch by ID + consumes: + - application/json + description: Get a single scanner template by ID parameters: - - description: Repository ID - in: path - name: repository_id - required: true - type: string - - description: Branch ID + - description: Template ID in: path name: id required: true @@ -19686,163 +20070,230 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BranchResponse' + $ref: '#/definitions/internal_infra_http_handler.ScannerTemplateResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get branch + summary: Get scanner template tags: - - Branches + - Scanner Templates put: consumes: - application/json - description: Updates a branch + description: Update an existing scanner template parameters: - - description: Repository ID - in: path - name: repository_id - required: true - type: string - - description: Branch ID + - description: Template ID in: path name: id required: true type: string - - description: Branch data + - description: Update data in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateBranchRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateScannerTemplateRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BranchResponse' + $ref: '#/definitions/internal_infra_http_handler.ScannerTemplateResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update branch + summary: Update scanner template tags: - - Branches - /repositories/{repository_id}/branches/{id}/default: - put: - description: Sets a branch as the default for a repository + - Scanner Templates + /scanner-templates/{id}/deprecate: + post: + consumes: + - application/json + description: Mark a scanner template as deprecated + parameters: + - description: Template ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.ScannerTemplateResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Deprecate template + tags: + - Scanner Templates + /scanner-templates/{id}/download: + get: + description: Download the raw template content as a file parameters: - - description: Repository ID - in: path - name: repository_id - required: true - type: string - - description: Branch ID + - description: Template ID in: path name: id required: true type: string produces: - - application/json + - application/octet-stream responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BranchResponse' + type: file "400": description: Bad Request schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Set default branch + summary: Download template content tags: - - Branches - /repositories/{repository_id}/branches/default: + - Scanner Templates + /scanner-templates/usage: get: - description: Gets the default branch for a repository + description: Get the current template usage and quota limits for the tenant + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.TemplateUsageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Get template usage and quota + tags: + - Scanner Templates + /scanner-templates/validate: + post: + consumes: + - application/json + description: Validate scanner template content without saving parameters: - - description: Repository ID - in: path - name: repository_id + - description: Template content to validate + in: body + name: body required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.ValidateScannerTemplateRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BranchResponse' + $ref: '#/definitions/internal_infra_http_handler.ValidationResultResponse' "400": description: Bad Request schema: - additionalProperties: - type: string - type: object - "404": - description: Not Found + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "413": + description: Request Entity Too Large schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get default branch + summary: Validate template content tags: - - Branches - /scan-profiles: + - Scanner Templates + /scans: get: consumes: - application/json - description: Get a paginated list of scan profiles for the current tenant + description: Get a paginated list of scans parameters: - - description: Filter by default status + - description: Filter by asset group in: query - name: is_default - type: boolean - - description: Filter by system status + name: asset_group_id + type: string + - description: Filter by pipeline in: query - name: is_system - type: boolean - - description: 'Include system profiles in results (default: true)' + name: pipeline_id + type: string + - description: Filter by scan type (workflow, single) in: query - name: include_system - type: boolean - - description: Filter by tags (comma-separated) + name: scan_type + type: string + - description: Filter by schedule type in: query - name: tags + name: schedule_type type: string - - description: Search by name or description + - description: Filter by status + in: query + name: status + type: string + - description: Search by name in: query name: search type: string @@ -19862,7 +20313,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanProfileResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanDetailResponse' "400": description: Bad Request schema: @@ -19873,33 +20324,33 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List scan profiles + summary: List scans tags: - - Scan Profiles + - Scans post: consumes: - application/json - description: Create a new scan profile with tool configurations + description: Create a new scan configuration with scheduling options parameters: - - description: Scan profile data + - description: Scan configuration in: body - name: body + name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateScanProfileRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateScanRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Conflict + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -19908,16 +20359,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create scan profile + summary: Create scan tags: - - Scan Profiles - /scan-profiles/{id}: + - Scans + /scans/{id}: delete: consumes: - application/json - description: Delete a scan profile (system profiles cannot be deleted) + description: Delete a scan configuration parameters: - - description: Scan Profile ID + - description: Scan ID in: path name: id required: true @@ -19931,10 +20382,6 @@ paths: description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: @@ -19945,15 +20392,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete scan profile + summary: Delete scan tags: - - Scan Profiles + - Scans get: consumes: - application/json - description: Get a single scan profile by ID + description: Get a single scan by ID parameters: - - description: Scan Profile ID + - description: Scan ID in: path name: id required: true @@ -19964,7 +20411,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' "400": description: Bad Request schema: @@ -19979,77 +20426,32 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scan profile + summary: Get scan tags: - - Scan Profiles + - Scans put: consumes: - application/json - description: Update an existing scan profile + description: Update an existing scan configuration parameters: - - description: Scan Profile ID + - description: Scan ID in: path name: id required: true type: string - description: Update data in: body - name: body + name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateScanProfileRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateScanRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Update scan profile - tags: - - Scan Profiles - /scan-profiles/{id}/clone: - post: - consumes: - - application/json - description: Create a copy of an existing scan profile with a new name - parameters: - - description: Scan Profile ID to clone - in: path - name: id - required: true - type: string - - description: Clone data - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CloneScanProfileRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' "400": description: Bad Request schema: @@ -20058,43 +20460,33 @@ paths: description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Conflict - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Clone scan profile + summary: Update scan tags: - - Scan Profiles - /scan-profiles/{id}/evaluate-quality-gate: + - Scans + /scans/{id}/activate: post: consumes: - application/json - description: Evaluate finding counts against a scan profile's quality gate + description: Activate a paused or disabled scan parameters: - - description: Scan Profile ID + - description: Scan ID in: path name: id required: true type: string - - description: Finding counts - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.EvaluateQualityGateRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.QualityGateResultResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' "400": description: Bad Request schema: @@ -20109,41 +20501,34 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Evaluate quality gate + summary: Activate scan tags: - - Scan Profiles - /scan-profiles/{id}/quality-gate: - put: - consumes: - - application/json - description: Update the quality gate configuration for a scan profile + - Scans + /scans/{id}/ci-snippet: + get: + description: Generate a CI/CD pipeline snippet for a scan configuration parameters: - - description: Scan Profile ID + - description: Scan ID in: path name: id required: true type: string - - description: Quality gate configuration - in: body - name: body + - description: CI/CD platform (github, gitlab, jenkins) + in: query + name: platform required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateQualityGateRequest' + type: string produces: - - application/json + - text/plain responses: "200": - description: OK + description: YAML or pipeline snippet schema: - $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + type: string "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: @@ -20154,27 +20539,33 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update quality gate + summary: Generate CI/CD snippet tags: - - Scan Profiles - /scan-profiles/{id}/set-default: + - Scans + /scans/{id}/clone: post: consumes: - application/json - description: Set a scan profile as the default for the tenant + description: Create a copy of an existing scan with a new name parameters: - - description: Scan Profile ID + - description: Scan ID to clone in: path name: id required: true type: string + - description: New scan name + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.CloneScanRequest' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' "400": description: Bad Request schema: @@ -20189,21 +20580,31 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Set default scan profile + summary: Clone scan tags: - - Scan Profiles - /scan-profiles/default: - get: + - Scans + /scans/{id}/disable: + post: consumes: - application/json - description: Get the default scan profile for the current tenant + description: Disable a scan completely + parameters: + - description: Scan ID + in: path + name: id + required: true + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanProfileResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "404": description: Not Found schema: @@ -20214,64 +20615,49 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get default scan profile + summary: Disable scan tags: - - Scan Profiles - /scan-sessions: + - Scans + /scans/{id}/export: get: - consumes: - - application/json - description: Get a paginated list of scan sessions for the tenant + description: Export a scan configuration as a JSON file for sharing or backup parameters: - - description: Filter by scanner name - in: query - name: scanner_name - type: string - - description: Filter by asset type - in: query - name: asset_type - type: string - - description: Filter by status - in: query - name: status + - description: Scan ID + in: path + name: id + required: true type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer produces: - application/json responses: "200": - description: OK + description: Scan configuration JSON schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanSessionResponse' + type: object "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List scan sessions + summary: Export scan configuration tags: - - Scan Sessions - /scan-sessions/{id}: - delete: + - Scans + /scans/{id}/pause: + post: consumes: - application/json - description: Delete a scan session + description: Pause an active scan parameters: - - description: Scan session ID + - description: Scan ID in: path name: id required: true @@ -20279,8 +20665,10 @@ paths: produces: - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' "400": description: Bad Request schema: @@ -20295,26 +20683,37 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete scan session + summary: Pause scan tags: - - Scan Sessions + - Scans + /scans/{id}/runs: get: consumes: - application/json - description: Get a single scan session by ID + description: Get a paginated list of runs for a scan parameters: - - description: Scan session ID + - description: Scan ID in: path name: id required: true type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanSessionResponse' + type: object "400": description: Bad Request schema: @@ -20329,18 +20728,24 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scan session + summary: List scan runs tags: - - Scan Sessions - /scan-sessions/stats: + - Scans + /scans/{id}/runs/{runId}: get: consumes: - application/json - description: Get aggregated scan session statistics + description: Get a specific run for a scan parameters: - - description: Start date (RFC3339 format) - in: query - name: since + - description: Scan ID + in: path + name: id + required: true + type: string + - description: Run ID + in: path + name: runId + required: true type: string produces: - application/json @@ -20354,95 +20759,86 @@ paths: description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scan session statistics + summary: Get scan run tags: - - Scan Sessions - /scanner-templates: + - Scans + /scans/{id}/runs/latest: get: consumes: - application/json - description: Get a paginated list of scanner templates for the current tenant + description: Get the most recent run for a scan parameters: - - description: Filter by template type (nuclei, semgrep, gitleaks) - in: query - name: template_type - type: string - - description: Filter by status (active, pending_review, deprecated, revoked) - in: query - name: status - type: string - - description: Filter by tags (comma-separated) - in: query - name: tags - type: string - - description: Search by name or description - in: query - name: search + - description: Scan ID + in: path + name: id + required: true type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScannerTemplateResponse' + additionalProperties: true + type: object "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List scanner templates + summary: Get latest scan run tags: - - Scanner Templates + - Scans + /scans/{id}/trigger: post: consumes: - application/json - description: Create a new custom scanner template (Nuclei, Semgrep, or Gitleaks) + description: Manually trigger a scan execution. Returns run details with optional + filtering_result showing which assets will be scanned vs skipped based on + scanner compatibility. parameters: - - description: Template data - in: body - name: body + - description: Scan ID + in: path + name: id required: true + type: string + - description: Trigger context + in: body + name: request schema: - $ref: '#/definitions/internal_infra_http_handler.CreateScannerTemplateRequest' + $ref: '#/definitions/internal_infra_http_handler.TriggerScanExecRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.ScannerTemplateResponse' + $ref: '#/definitions/internal_infra_http_handler.RunResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Conflict - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "413": - description: Request Entity Too Large + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -20451,35 +20847,34 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create scanner template + summary: Trigger scan tags: - - Scanner Templates - /scanner-templates/{id}: - delete: + - Scans + /scans/bulk/activate: + post: consumes: - application/json - description: Delete a scanner template + description: Activate multiple scan configurations at once parameters: - - description: Template ID - in: path - name: id + - description: Scan IDs to activate + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkActionRequest' produces: - application/json responses: - "204": - description: No Content + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkActionResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -20488,32 +20883,34 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete scanner template + summary: Bulk activate scans tags: - - Scanner Templates - get: + - Scans + /scans/bulk/delete: + post: consumes: - application/json - description: Get a single scanner template by ID + description: Delete multiple scan configurations at once parameters: - - description: Template ID - in: path - name: id + - description: Scan IDs to delete + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkActionRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScannerTemplateResponse' + $ref: '#/definitions/internal_infra_http_handler.BulkActionResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -20522,46 +20919,34 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scanner template + summary: Bulk delete scans tags: - - Scanner Templates - put: + - Scans + /scans/bulk/disable: + post: consumes: - application/json - description: Update an existing scanner template + description: Disable multiple scan configurations at once parameters: - - description: Template ID - in: path - name: id - required: true - type: string - - description: Update data + - description: Scan IDs to disable in: body - name: body + name: request required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateScannerTemplateRequest' + $ref: '#/definitions/internal_infra_http_handler.BulkActionRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScannerTemplateResponse' + $ref: '#/definitions/internal_infra_http_handler.BulkActionResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "413": - description: Request Entity Too Large + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -20570,37 +20955,34 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update scanner template + summary: Bulk disable scans tags: - - Scanner Templates - /scanner-templates/{id}/deprecate: + - Scans + /scans/bulk/pause: post: consumes: - application/json - description: Mark a scanner template as deprecated + description: Pause multiple scan configurations at once parameters: - - description: Template ID - in: path - name: id + - description: Scan IDs to pause + in: body + name: request required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkActionRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScannerTemplateResponse' + $ref: '#/definitions/internal_infra_http_handler.BulkActionResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "403": - description: Forbidden - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -20609,54 +20991,76 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Deprecate template + summary: Bulk pause scans tags: - - Scanner Templates - /scanner-templates/{id}/download: + - Scans + /scans/coverage: get: - description: Download the raw template content as a file + description: |- + License-aware rolling coverage summary for the tenant's scannable + estate (RFC-007): how much was scanned within the freshness window, + what is stale or never scanned, and the critical-asset risk. parameters: - - description: Template ID - in: path - name: id - required: true - type: string + - description: Freshness window in days (default 30, max 3650) + in: query + name: window_days + type: integer produces: - - application/octet-stream + - application/json responses: "200": description: OK schema: - type: file + $ref: '#/definitions/github_com_openctemio_api_internal_app_scancoverage.CoverageStats' + summary: Scan coverage status + tags: + - Scans + /scans/import: + post: + consumes: + - application/json + description: Create a new scan from an imported JSON configuration + parameters: + - description: Scan configuration JSON (exported format) + in: body + name: request + required: true + schema: + type: object + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Download template content + summary: Import scan configuration tags: - - Scanner Templates - /scanner-templates/usage: + - Scans + /scans/stats: get: - description: Get the current template usage and quota limits for the tenant + consumes: + - application/json + description: Get aggregated statistics for all scans produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.TemplateUsageResponse' - "400": - description: Bad Request + $ref: '#/definitions/internal_infra_http_handler.ScanStatsResponse' + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -20665,34 +21069,35 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get template usage and quota + summary: Get scan statistics tags: - - Scanner Templates - /scanner-templates/validate: + - Scans + /scope/check: post: consumes: - application/json - description: Validate scanner template content without saving + description: Check whether a given asset type and value falls within scope targets + and exclusions parameters: - - description: Template content to validate + - description: Asset type and value to check in: body name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.ValidateScannerTemplateRequest' + $ref: '#/definitions/internal_infra_http_handler.CheckScopeRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ValidationResultResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeMatchResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "413": - description: Request Entity Too Large + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -20701,36 +21106,28 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Validate template content + summary: Check if value is in scope tags: - - Scanner Templates - /scans: + - Scope + /scope/exclusions: get: consumes: - application/json - description: Get a paginated list of scans + description: Get a paginated list of scope exclusions for the current tenant parameters: - - description: Filter by asset group - in: query - name: asset_group_id - type: string - - description: Filter by pipeline - in: query - name: pipeline_id - type: string - - description: Filter by scan type (workflow, single) + - description: Filter by exclusion types (comma-separated) in: query - name: scan_type + name: types type: string - - description: Filter by schedule type + - description: Filter by statuses (comma-separated) in: query - name: schedule_type + name: statuses type: string - - description: Filter by status + - description: Filter by approval status in: query - name: status - type: string - - description: Search by name + name: is_approved + type: boolean + - description: Search by pattern in: query name: search type: string @@ -20750,44 +21147,48 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanDetailResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScopeExclusionResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List scans + summary: List scope exclusions tags: - - Scans + - Scope post: consumes: - application/json - description: Create a new scan configuration with scheduling options + description: Create a new scope exclusion parameters: - - description: Scan configuration + - description: Scope exclusion data in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateScanRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateScopeExclusionRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "409": + description: Conflict schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -20796,16 +21197,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create scan + summary: Create scope exclusion tags: - - Scans - /scans/{id}: + - Scope + /scope/exclusions/{id}: delete: consumes: - application/json - description: Delete a scan configuration + description: Delete a scope exclusion parameters: - - description: Scan ID + - description: Exclusion ID in: path name: id required: true @@ -20829,15 +21230,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete scan + summary: Delete scope exclusion tags: - - Scans + - Scope get: consumes: - application/json - description: Get a single scan by ID + description: Get a single scope exclusion by ID parameters: - - description: Scan ID + - description: Exclusion ID in: path name: id required: true @@ -20848,7 +21249,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' "400": description: Bad Request schema: @@ -20863,32 +21264,32 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scan + summary: Get scope exclusion tags: - - Scans + - Scope put: consumes: - application/json - description: Update an existing scan configuration + description: Update an existing scope exclusion parameters: - - description: Scan ID + - description: Exclusion ID in: path name: id required: true type: string - description: Update data in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateScanRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateScopeExclusionRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' "400": description: Bad Request schema: @@ -20903,16 +21304,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update scan + summary: Update scope exclusion tags: - - Scans - /scans/{id}/activate: + - Scope + /scope/exclusions/{id}/activate: post: consumes: - application/json - description: Activate a paused or disabled scan + description: Activate a scope exclusion parameters: - - description: Scan ID + - description: Exclusion ID in: path name: id required: true @@ -20923,7 +21324,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' "400": description: Bad Request schema: @@ -20938,33 +21339,27 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Activate scan + summary: Activate scope exclusion tags: - - Scans - /scans/{id}/clone: + - Scope + /scope/exclusions/{id}/approve: post: consumes: - application/json - description: Create a copy of an existing scan with a new name + description: Approve a scope exclusion, marking it as reviewed and authorized parameters: - - description: Scan ID to clone + - description: Exclusion ID in: path name: id required: true type: string - - description: New scan name - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CloneScanRequest' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' "400": description: Bad Request schema: @@ -20979,16 +21374,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Clone scan + summary: Approve scope exclusion tags: - - Scans - /scans/{id}/disable: + - Scope + /scope/exclusions/{id}/deactivate: post: consumes: - application/json - description: Disable a scan completely + description: Deactivate a scope exclusion parameters: - - description: Scan ID + - description: Exclusion ID in: path name: id required: true @@ -20999,7 +21394,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' "400": description: Bad Request schema: @@ -21014,33 +21409,34 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Disable scan + summary: Deactivate scope exclusion tags: - - Scans - /scans/{id}/pause: + - Scope + /scope/exclusions/bulk/delete: post: consumes: - application/json - description: Pause an active scan + description: Delete multiple scope exclusions in a single operation parameters: - - description: Scan ID - in: path - name: id + - description: Exclusion IDs to delete + in: body + name: body required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkDeleteExclusionsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanDetailResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeBulkOperationResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -21049,19 +21445,30 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Pause scan + summary: Bulk delete scope exclusions tags: - - Scans - /scans/{id}/runs: + - Scope + /scope/schedules: get: consumes: - application/json - description: Get a paginated list of runs for a scan + description: Get a paginated list of scan schedules for the current tenant parameters: - - description: Scan ID - in: path - name: id - required: true + - description: Filter by scan types (comma-separated) + in: query + name: scan_types + type: string + - description: Filter by schedule types (comma-separated) + in: query + name: schedule_types + type: string + - description: Filter by enabled status + in: query + name: enabled + type: boolean + - description: Search by name + in: query + name: search type: string - default: 1 description: Page number @@ -21079,13 +21486,13 @@ paths: "200": description: OK schema: - type: object + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanScheduleResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -21094,33 +21501,60 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List scan runs + summary: List scan schedules tags: - - Scans - /scans/{id}/runs/{runId}: - get: + - Scope + post: consumes: - application/json - description: Get a specific run for a scan + description: Create a new scan schedule parameters: - - description: Scan ID - in: path - name: id + - description: Scan schedule data + in: body + name: body required: true - type: string - - description: Run ID + schema: + $ref: '#/definitions/internal_infra_http_handler.CreateScanScheduleRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_infra_http_handler.ScanScheduleResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' + security: + - BearerAuth: [] + summary: Create scan schedule + tags: + - Scope + /scope/schedules/{id}: + delete: + consumes: + - application/json + description: Delete a scan schedule + parameters: + - description: Schedule ID in: path - name: runId + name: id required: true type: string produces: - application/json responses: - "200": - description: OK - schema: - additionalProperties: true - type: object + "204": + description: No Content "400": description: Bad Request schema: @@ -21135,16 +21569,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scan run + summary: Delete scan schedule tags: - - Scans - /scans/{id}/runs/latest: + - Scope get: consumes: - application/json - description: Get the most recent run for a scan + description: Get a single scan schedule by ID parameters: - - description: Scan ID + - description: Schedule ID in: path name: id required: true @@ -21155,8 +21588,7 @@ paths: "200": description: OK schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_infra_http_handler.ScanScheduleResponse' "400": description: Bad Request schema: @@ -21171,34 +21603,32 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get latest scan run + summary: Get scan schedule tags: - - Scans - /scans/{id}/trigger: - post: + - Scope + put: consumes: - application/json - description: Manually trigger a scan execution. Returns run details with optional - filtering_result showing which assets will be scanned vs skipped based on - scanner compatibility. + description: Update an existing scan schedule parameters: - - description: Scan ID + - description: Schedule ID in: path name: id required: true type: string - - description: Trigger context + - description: Update data in: body - name: request + name: body + required: true schema: - $ref: '#/definitions/internal_infra_http_handler.TriggerScanExecRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateScanScheduleRequest' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.RunResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanScheduleResponse' "400": description: Bad Request schema: @@ -21213,34 +21643,33 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Trigger scan + summary: Update scan schedule tags: - - Scans - /scans/bulk/activate: + - Scope + /scope/schedules/{id}/disable: post: consumes: - application/json - description: Activate multiple scan configurations at once + description: Disable a scan schedule so it will no longer run automatically parameters: - - description: Scan IDs to activate - in: body - name: request + - description: Schedule ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.BulkActionRequest' + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BulkActionResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanScheduleResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -21249,34 +21678,33 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Bulk activate scans + summary: Disable scan schedule tags: - - Scans - /scans/bulk/delete: + - Scope + /scope/schedules/{id}/enable: post: consumes: - application/json - description: Delete multiple scan configurations at once + description: Enable a scan schedule so it will run on its configured schedule parameters: - - description: Scan IDs to delete - in: body - name: request + - description: Schedule ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.BulkActionRequest' + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BulkActionResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanScheduleResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -21285,34 +21713,33 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Bulk delete scans + summary: Enable scan schedule tags: - - Scans - /scans/bulk/disable: + - Scope + /scope/schedules/{id}/run: post: consumes: - application/json - description: Disable multiple scan configurations at once + description: Trigger an immediate run of a scan schedule parameters: - - description: Scan IDs to disable - in: body - name: request + - description: Schedule ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.BulkActionRequest' + type: string produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BulkActionResponse' + $ref: '#/definitions/internal_infra_http_handler.ScanScheduleResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -21321,28 +21748,28 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Bulk disable scans + summary: Run scan schedule now tags: - - Scans - /scans/bulk/pause: + - Scope + /scope/schedules/bulk/delete: post: consumes: - application/json - description: Pause multiple scan configurations at once + description: Delete multiple scan schedules in a single operation parameters: - - description: Scan IDs to pause + - description: Schedule IDs to delete in: body - name: request + name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.BulkActionRequest' + $ref: '#/definitions/internal_infra_http_handler.BulkDeleteSchedulesRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.BulkActionResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeBulkOperationResponse' "400": description: Bad Request schema: @@ -21357,21 +21784,19 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Bulk pause scans + summary: Bulk delete scan schedules tags: - - Scans - /scans/stats: + - Scope + /scope/stats: get: - consumes: - - application/json - description: Get aggregated statistics for all scans + description: Get aggregate statistics for scope targets, exclusions, and schedules produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanStatsResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeStatsResponse' "401": description: Unauthorized schema: @@ -21382,16 +21807,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scan statistics + summary: Get scope statistics tags: - - Scans - /scope/exclusions: + - Scope + /scope/targets: get: consumes: - application/json - description: Get a paginated list of scope exclusions for the current tenant + description: Get a paginated list of scope targets for the current tenant parameters: - - description: Filter by exclusion types (comma-separated) + - description: Filter by target types (comma-separated) in: query name: types type: string @@ -21399,10 +21824,10 @@ paths: in: query name: statuses type: string - - description: Filter by approval status + - description: Filter by tags (comma-separated) in: query - name: is_approved - type: boolean + name: tags + type: string - description: Search by pattern in: query name: search @@ -21423,7 +21848,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScopeExclusionResponse' + $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScopeTargetResponse' "400": description: Bad Request schema: @@ -21438,27 +21863,27 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List scope exclusions + summary: List scope targets tags: - Scope post: consumes: - application/json - description: Create a new scope exclusion + description: Create a new scope target parameters: - - description: Scope exclusion data + - description: Scope target data in: body name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateScopeExclusionRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateScopeTargetRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' "400": description: Bad Request schema: @@ -21473,16 +21898,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create scope exclusion + summary: Create scope target tags: - Scope - /scope/exclusions/{id}: + /scope/targets/{id}: delete: consumes: - application/json - description: Delete a scope exclusion + description: Delete a scope target parameters: - - description: Exclusion ID + - description: Target ID in: path name: id required: true @@ -21506,15 +21931,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Delete scope exclusion + summary: Delete scope target tags: - Scope get: consumes: - application/json - description: Get a single scope exclusion by ID + description: Get a single scope target by ID parameters: - - description: Exclusion ID + - description: Target ID in: path name: id required: true @@ -21525,7 +21950,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' "400": description: Bad Request schema: @@ -21540,15 +21965,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scope exclusion + summary: Get scope target tags: - Scope put: consumes: - application/json - description: Update an existing scope exclusion + description: Update an existing scope target parameters: - - description: Exclusion ID + - description: Target ID in: path name: id required: true @@ -21558,14 +21983,14 @@ paths: name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateScopeExclusionRequest' + $ref: '#/definitions/internal_infra_http_handler.UpdateScopeTargetRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScopeExclusionResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' "400": description: Bad Request schema: @@ -21580,54 +22005,33 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update scope exclusion + summary: Update scope target tags: - Scope - /scope/schedules: - get: + /scope/targets/{id}/activate: + post: consumes: - application/json - description: Get a paginated list of scan schedules for the current tenant + description: Activate a scope target parameters: - - description: Filter by scan types (comma-separated) - in: query - name: scan_types - type: string - - description: Filter by schedule types (comma-separated) - in: query - name: schedule_types - type: string - - description: Filter by enabled status - in: query - name: enabled - type: boolean - - description: Search by name - in: query - name: search + - description: Target ID + in: path + name: id + required: true type: string - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 20 - description: Items per page - in: query - name: per_page - type: integer produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScanScheduleResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -21636,33 +22040,33 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List scan schedules + summary: Activate scope target tags: - Scope + /scope/targets/{id}/deactivate: post: consumes: - application/json - description: Create a new scan schedule + description: Deactivate a scope target parameters: - - description: Scan schedule data - in: body - name: body + - description: Target ID + in: path + name: id required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.CreateScanScheduleRequest' + type: string produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanScheduleResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "409": - description: Conflict + "404": + description: Not Found schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -21671,33 +22075,34 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create scan schedule + summary: Deactivate scope target tags: - Scope - /scope/schedules/{id}: - get: + /scope/targets/bulk/delete: + post: consumes: - application/json - description: Get a single scan schedule by ID + description: Delete multiple scope targets in a single operation parameters: - - description: Schedule ID - in: path - name: id + - description: Target IDs to delete + in: body + name: body required: true - type: string + schema: + $ref: '#/definitions/internal_infra_http_handler.BulkDeleteTargetsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScanScheduleResponse' + $ref: '#/definitions/internal_infra_http_handler.ScopeBulkOperationResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found + "401": + description: Unauthorized schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": @@ -21706,40 +22111,26 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get scan schedule + summary: Bulk delete scope targets tags: - Scope - /scope/targets: + /secret-store: get: consumes: - application/json - description: Get a paginated list of scope targets for the current tenant + description: List credentials with optional filters parameters: - - description: Filter by target types (comma-separated) - in: query - name: types - type: string - - description: Filter by statuses (comma-separated) - in: query - name: statuses - type: string - - description: Filter by tags (comma-separated) - in: query - name: tags - type: string - - description: Search by pattern + - description: Filter by credential type in: query - name: search + name: credential_type type: string - - default: 1 - description: Page number + - description: Page number in: query name: page type: integer - - default: 20 - description: Items per page + - description: Page size in: query - name: per_page + name: page_size type: integer produces: - application/json @@ -21747,42 +22138,38 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ListResponse-internal_infra_http_handler_ScopeTargetResponse' + $ref: '#/definitions/internal_infra_http_handler.ListCredentialsResponse' "400": description: Bad Request schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' "500": description: Internal Server Error schema: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List scope targets + summary: List credentials tags: - - Scope + - Credentials post: consumes: - application/json - description: Create a new scope target + description: Create a new credential for template sources parameters: - - description: Scope target data + - description: Credential data in: body name: body required: true schema: - $ref: '#/definitions/internal_infra_http_handler.CreateScopeTargetRequest' + $ref: '#/definitions/internal_infra_http_handler.CreateCredentialRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' + $ref: '#/definitions/internal_infra_http_handler.CredentialResponse' "400": description: Bad Request schema: @@ -21797,99 +22184,25 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Create scope target + summary: Create credential tags: - - Scope - /scope/targets/{id}: + - Credentials + /secret-store/{id}: delete: consumes: - application/json - description: Delete a scope target - parameters: - - description: Target ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "204": - description: No Content - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Delete scope target - tags: - - Scope - get: - consumes: - - application/json - description: Get a single scope target by ID - parameters: - - description: Target ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "404": - description: Not Found - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' - security: - - BearerAuth: [] - summary: Get scope target - tags: - - Scope - put: - consumes: - - application/json - description: Update an existing scope target + description: Delete a credential parameters: - - description: Target ID + - description: Credential ID in: path name: id required: true type: string - - description: Update data - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_infra_http_handler.UpdateScopeTargetRequest' produces: - application/json responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' + "204": + description: No Content "400": description: Bad Request schema: @@ -21904,16 +22217,15 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Update scope target + summary: Delete credential tags: - - Scope - /scope/targets/{id}/activate: - post: + - Credentials + get: consumes: - application/json - description: Activate a scope target + description: Get a single credential by ID (without sensitive data) parameters: - - description: Target ID + - description: Credential ID in: path name: id required: true @@ -21924,7 +22236,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' + $ref: '#/definitions/internal_infra_http_handler.CredentialResponse' "400": description: Bad Request schema: @@ -21939,27 +22251,32 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Activate scope target + summary: Get credential tags: - - Scope - /scope/targets/{id}/deactivate: - post: + - Credentials + put: consumes: - application/json - description: Deactivate a scope target + description: Update credential metadata (not sensitive data) parameters: - - description: Target ID + - description: Credential ID in: path name: id required: true type: string + - description: Updated credential data + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_infra_http_handler.UpdateCredentialRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_infra_http_handler.ScopeTargetResponse' + $ref: '#/definitions/internal_infra_http_handler.CredentialResponse' "400": description: Bad Request schema: @@ -21974,9 +22291,9 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Deactivate scope target + summary: Update credential tags: - - Scope + - Credentials /services: get: consumes: @@ -22180,7 +22497,9 @@ paths: get: consumes: - application/json - description: Retrieves a paginated list of publicly exposed services + description: |- + Deprecated: use GET /services?exposure=public instead. + Retrieves a paginated list of publicly exposed services. parameters: - default: 50 description: Maximum results (max 1000) @@ -22220,7 +22539,7 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: List public services + summary: List public services (deprecated) tags: - Asset Services /services/stats: @@ -22444,10 +22763,16 @@ paths: get: consumes: - application/json - description: Retrieves a paginated list of all state changes for the tenant - with optional filtering + description: |- + Retrieves a paginated list of all state changes for the tenant with optional filtering. + Use ?event_type= with comma-separated values to filter by one or more change types + (e.g. ?event_type=appeared,disappeared replaces the old /appearances and /disappearances endpoints). parameters: - - description: Filter by change type + - description: Comma-separated change types (appeared,disappeared,shadow_it,exposure_changed,...) + in: query + name: event_type + type: string + - description: 'Filter by single change type (deprecated: use event_type)' in: query name: change_type type: string @@ -22547,7 +22872,9 @@ paths: get: consumes: - application/json - description: Retrieves recently discovered assets (new assets appearing in scans) + description: |- + Deprecated: use GET /state-history?event_type=appeared instead. + Retrieves recently discovered assets (new assets appearing in scans). parameters: - description: 'Start time (RFC3339, default: 7 days ago)' in: query @@ -22569,8 +22896,10 @@ paths: items: $ref: '#/definitions/internal_infra_http_handler.StateChangeResponse' type: array - since: - type: string + limit: + type: integer + offset: + type: integer total: type: integer type: object @@ -22584,14 +22913,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get recent asset appearances + summary: Get recent asset appearances (deprecated) tags: - Asset State History /state-history/compliance: get: consumes: - application/json - description: Retrieves state changes that may affect compliance status + description: |- + Deprecated: use GET /state-history?event_type=compliance_changed,classification_changed,owner_changed instead. + Retrieves state changes that may affect compliance status. parameters: - description: 'Start time (RFC3339, default: 7 days ago)' in: query @@ -22613,8 +22944,10 @@ paths: items: $ref: '#/definitions/internal_infra_http_handler.StateChangeResponse' type: array - since: - type: string + limit: + type: integer + offset: + type: integer total: type: integer type: object @@ -22628,14 +22961,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get compliance-related changes + summary: Get compliance-related changes (deprecated) tags: - Asset State History /state-history/disappearances: get: consumes: - application/json - description: Retrieves assets that have disappeared (no longer seen in scans) + description: |- + Deprecated: use GET /state-history?event_type=disappeared instead. + Retrieves assets that have disappeared (no longer seen in scans). parameters: - description: 'Start time (RFC3339, default: 7 days ago)' in: query @@ -22657,8 +22992,10 @@ paths: items: $ref: '#/definitions/internal_infra_http_handler.StateChangeResponse' type: array - since: - type: string + limit: + type: integer + offset: + type: integer total: type: integer type: object @@ -22672,14 +23009,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get recent asset disappearances + summary: Get recent asset disappearances (deprecated) tags: - Asset State History /state-history/exposure-changes: get: consumes: - application/json - description: Retrieves assets that have changed exposure status (public/private/restricted) + description: |- + Deprecated: use GET /state-history?event_type=exposure_changed,internet_exposure_changed instead. + Retrieves assets that have changed exposure status (public/private/restricted). parameters: - description: 'Start time (RFC3339, default: 7 days ago)' in: query @@ -22701,8 +23040,10 @@ paths: items: $ref: '#/definitions/internal_infra_http_handler.StateChangeResponse' type: array - since: - type: string + limit: + type: integer + offset: + type: integer total: type: integer type: object @@ -22716,14 +23057,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get exposure changes + summary: Get exposure changes (deprecated) tags: - Asset State History /state-history/newly-exposed: get: consumes: - application/json - description: Retrieves assets that have recently become publicly exposed + description: |- + Deprecated: use GET /state-history?event_type=internet_exposure_changed instead. + Retrieves assets that have recently become publicly exposed. parameters: - description: 'Start time (RFC3339, default: 7 days ago)' in: query @@ -22745,8 +23088,10 @@ paths: items: $ref: '#/definitions/internal_infra_http_handler.StateChangeResponse' type: array - since: - type: string + limit: + type: integer + offset: + type: integer total: type: integer type: object @@ -22760,15 +23105,16 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get newly exposed assets + summary: Get newly exposed assets (deprecated) tags: - Asset State History /state-history/shadow-it: get: consumes: - application/json - description: Retrieves assets identified as potential Shadow IT (unexpected - or unauthorized resources) + description: |- + Deprecated: use GET /state-history?event_type=appeared with scope filtering instead. + Retrieves assets identified as potential Shadow IT (appeared with shadow scope). parameters: - description: 'Start time (RFC3339, default: 7 days ago)' in: query @@ -22790,8 +23136,10 @@ paths: items: $ref: '#/definitions/internal_infra_http_handler.StateChangeResponse' type: array - since: - type: string + limit: + type: integer + offset: + type: integer total: type: integer type: object @@ -22805,7 +23153,7 @@ paths: $ref: '#/definitions/github_com_openctemio_api_pkg_apierror.Error' security: - BearerAuth: [] - summary: Get Shadow IT candidates + summary: Get Shadow IT candidates (deprecated) tags: - Asset State History /state-history/stats: @@ -23426,7 +23774,7 @@ paths: summary: List all tools with tenant config tags: - Tenant Tools - /tenant-tools/bulk-disable: + /tenant-tools/bulk/disable: post: consumes: - application/json @@ -23456,7 +23804,7 @@ paths: summary: Bulk disable tools tags: - Tenant Tools - /tenant-tools/bulk-enable: + /tenant-tools/bulk/enable: post: consumes: - application/json @@ -23486,74 +23834,7 @@ paths: summary: Bulk enable tools tags: - Tenant Tools - /tenants/{tenant_id}/modules: - get: - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.TenantModulesResponse' - security: - - BearerAuth: [] - summary: Get tenant enabled modules - tags: - - Licensing - /tenants/{tenant_id}/modules/{module_id}: - get: - parameters: - - description: Module ID - in: path - name: module_id - required: true - type: string - responses: - "200": - description: OK - schema: - additionalProperties: - type: boolean - type: object - security: - - BearerAuth: [] - summary: Check tenant module access - tags: - - Licensing - /tenants/{tenant_id}/modules/{module_id}/limit: - get: - parameters: - - description: Module ID - in: path - name: module_id - required: true - type: string - - description: Metric name - in: query - name: metric - required: true - type: string - responses: - "200": - description: OK - schema: - $ref: '#/definitions/github_com_openctemio_api_internal_app.GetModuleLimitOutput' - security: - - BearerAuth: [] - summary: Get module limit - tags: - - Licensing - /tenants/{tenant_id}/subscription: - get: - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal_infra_http_handler.SubscriptionResponse' - security: - - BearerAuth: [] - summary: Get tenant subscription - tags: - - Licensing - /tool-stats: + /tenant-tools/stats: get: consumes: - application/json @@ -23584,7 +23865,7 @@ paths: summary: Get tenant tool stats tags: - Tool Stats - /tool-stats/{tool_id}: + /tenant-tools/stats/{toolId}: get: consumes: - application/json @@ -23592,7 +23873,7 @@ paths: parameters: - description: Tool ID in: path - name: tool_id + name: toolId required: true type: string - default: 30 @@ -24088,6 +24369,26 @@ paths: tags: - Authentication /users/me/preferences: + get: + description: Returns the preferences of the authenticated user + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_infra_http_handler.PreferencesDTO' + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get user preferences + tags: + - Users put: consumes: - application/json @@ -24421,6 +24722,125 @@ paths: summary: Update vulnerability tags: - Vulnerabilities + /vulnerabilities/{id}/affected-assets: + get: + description: Returns the assets in the current tenant affected by this CVE, + parameters: + - description: Vulnerability ID + in: path + name: id + required: true + type: string + - description: Include assets affected only by closed findings + in: query + name: include_resolved + type: boolean + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List assets affected by a CVE (blast-radius) + tags: + - Vulnerabilities + /vulnerabilities/active: + get: + description: 'Distinct CVEs that have at least one finding (default: open) on' + parameters: + - description: Include CVEs only seen in closed findings + in: query + name: include_resolved + type: boolean + - description: Comma-separated severities (critical,high,...) + in: query + name: severities + type: string + - description: Only return CISA KEV-listed CVEs + in: query + name: kev_only + type: boolean + - description: Minimum CVSS score + in: query + name: min_cvss + type: number + - description: Minimum EPSS score (0-1) + in: query + name: min_epss + type: number + - description: Only CVEs with public exploit + in: query + name: exploit_available + type: boolean + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: List CVEs currently impacting the tenant + tags: + - Vulnerabilities + /vulnerabilities/active/stats: + get: + description: Counts (total, by severity, KEV, exploit-available) for the + parameters: + - description: Include CVEs only seen in closed findings + in: query + name: include_resolved + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_openctemio_api_pkg_domain_vulnerability.ActiveCVEStats' + security: + - BearerAuth: [] + summary: Aggregate stats for CVEs currently impacting the tenant + tags: + - Vulnerabilities /vulnerabilities/cve/{cve_id}: get: description: Retrieves a vulnerability by CVE ID diff --git a/api/openapi/undocumented-routes.txt b/api/openapi/undocumented-routes.txt new file mode 100644 index 00000000..dd47c0de --- /dev/null +++ b/api/openapi/undocumented-routes.txt @@ -0,0 +1,463 @@ +# Registered routes that carry no // @Router annotation, and so appear in no +# OpenAPI document and in no generated client. +# +# This is a FROZEN DEBT LIST, not an allowlist to grow. When the spec became a +# generated artifact, 439 of the 878 registered routes had never been annotated +# — including, until this change, the entire /notifications API, +# GET /auth/providers and GET /scans/coverage. The UI could not see them, so +# the notification preferences page hand-maintained its own copy of the event +# catalogue and six event types ended up with no checkbox at all. +# +# Annotating all 439 is a large separate effort. What this file prevents is the +# debt GROWING: tools/lint/openapicontract fails when a route is neither +# documented nor listed here, so a new endpoint must either carry an @Router +# annotation or be added below in the same commit, where a reviewer sees the +# choice. +# +# To remove a line: add the annotation to the handler, run `make swagger`, and +# delete the entry. The test also fails on entries that no longer match a real +# route, so this file cannot rot. +# +# Paths are normalised the way the checker compares them: every path parameter +# is written {} , because a parameter rename is not a contract change. +# +# Format: METHOD /full/path +DELETE /api/v1/admin/target-mappings/{} +DELETE /api/v1/admin/users/{} +DELETE /api/v1/api-keys/{} +DELETE /api/v1/assets/{}/lifecycle/snooze +DELETE /api/v1/assets/{}/owners/{} +DELETE /api/v1/assignment-rules/{} +DELETE /api/v1/attachments/{} +DELETE /api/v1/attacker-profiles/{} +DELETE /api/v1/business-services/{} +DELETE /api/v1/business-services/{}/assets/{} +DELETE /api/v1/business-units/{} +DELETE /api/v1/business-units/{}/assets/{} +DELETE /api/v1/compensating-controls/{} +DELETE /api/v1/compliance/findings/{}/controls/{} +DELETE /api/v1/control-tests/{} +DELETE /api/v1/custom-capabilities/{} +DELETE /api/v1/custom-tool-categories/{} +DELETE /api/v1/findings/{}/comments/{} +DELETE /api/v1/findings/{}/evidence/notes/{} +DELETE /api/v1/findings/{}/link-ticket +DELETE /api/v1/groups/{}/scope-rules/{} +DELETE /api/v1/iocs/{} +DELETE /api/v1/pentest/campaigns/{} +DELETE /api/v1/pentest/campaigns/{}/members/{} +DELETE /api/v1/pentest/findings/{} +DELETE /api/v1/pentest/reports/{} +DELETE /api/v1/pentest/templates/{} +DELETE /api/v1/pipelines/{} +DELETE /api/v1/pipelines/{}/steps/{} +DELETE /api/v1/priority-rules/{} +DELETE /api/v1/relationships/{} +DELETE /api/v1/remediation/campaigns/{} +DELETE /api/v1/reports/schedules/{} +DELETE /api/v1/roles/{} +DELETE /api/v1/scim-tokens/{} +DELETE /api/v1/settings/identity-providers/{} +DELETE /api/v1/settings/saml +DELETE /api/v1/settings/verified-domains/{} +DELETE /api/v1/simulations/{} +DELETE /api/v1/suppressions/{} +DELETE /api/v1/tenants/{} +DELETE /api/v1/tenants/{}/invitations/{} +DELETE /api/v1/tenants/{}/members/{} +DELETE /api/v1/threat-actors/{} +DELETE /api/v1/users/{}/roles/{} +DELETE /api/v1/webhooks/{} +DELETE /api/v1/workflows/{} +DELETE /api/v1/workflows/{}/edges/{} +DELETE /api/v1/workflows/{}/nodes/{} +GET /api/v1/admin/auth/validate +GET /api/v1/admin/target-mappings +GET /api/v1/admin/target-mappings/stats +GET /api/v1/admin/target-mappings/{} +GET /api/v1/admin/users +GET /api/v1/admin/users/{} +GET /api/v1/agent/ingest/jobs/{} +GET /api/v1/agent/ingest/scanners +GET /api/v1/api-keys +GET /api/v1/api-keys/{} +GET /api/v1/approvals +GET /api/v1/assets/dedup/merge-log +GET /api/v1/assets/dedup/reviews +GET /api/v1/assets/facets +GET /api/v1/assets/tags +GET /api/v1/assets/{}/owners +GET /api/v1/assets/{}/relationships +GET /api/v1/assignment-rules +GET /api/v1/assignment-rules/{} +GET /api/v1/attachments +GET /api/v1/attachments/storage-config +GET /api/v1/attachments/{} +GET /api/v1/attachments/{}/meta +GET /api/v1/attack-surface/exposure-chains +GET /api/v1/attacker-profiles +GET /api/v1/attacker-profiles/{} +GET /api/v1/audit-logs/verify +GET /api/v1/auth/saml/{}/login +GET /api/v1/auth/saml/{}/metadata +GET /api/v1/auth/sso/providers +GET /api/v1/auth/sso/{}/authorize +GET /api/v1/business-services +GET /api/v1/business-services/{} +GET /api/v1/business-services/{}/assets +GET /api/v1/business-units +GET /api/v1/business-units/{} +GET /api/v1/capabilities +GET /api/v1/capabilities/all +GET /api/v1/capabilities/by-category/{} +GET /api/v1/capabilities/categories +GET /api/v1/capabilities/{} +GET /api/v1/capabilities/{}/usage-stats +GET /api/v1/compensating-controls +GET /api/v1/compensating-controls/{} +GET /api/v1/compliance/assessments +GET /api/v1/compliance/controls/{} +GET /api/v1/compliance/findings/{}/controls +GET /api/v1/compliance/frameworks +GET /api/v1/compliance/frameworks/{} +GET /api/v1/compliance/frameworks/{}/controls +GET /api/v1/compliance/frameworks/{}/stats +GET /api/v1/compliance/stats +GET /api/v1/components/export +GET /api/v1/control-tests +GET /api/v1/control-tests/stats +GET /api/v1/ctem-cycles +GET /api/v1/ctem-cycles/{} +GET /api/v1/ctem-cycles/{}/scope +GET /api/v1/dashboard/data-quality +GET /api/v1/dashboard/executive-summary +GET /api/v1/dashboard/executive-summary/export +GET /api/v1/dashboard/mttr +GET /api/v1/dashboard/mttr-analytics +GET /api/v1/dashboard/process-metrics +GET /api/v1/dashboard/risk-trend +GET /api/v1/dashboard/velocity +GET /api/v1/findings/ai-triage/config +GET /api/v1/findings/analytics/sources +GET /api/v1/findings/groups +GET /api/v1/findings/related-cves/{} +GET /api/v1/findings/{}/activities +GET /api/v1/findings/{}/activities/{} +GET /api/v1/findings/{}/ai-triage +GET /api/v1/findings/{}/ai-triage/history +GET /api/v1/findings/{}/ai-triage/{} +GET /api/v1/findings/{}/approvals +GET /api/v1/findings/{}/comments +GET /api/v1/findings/{}/evidence +GET /api/v1/findings/{}/evidence/notes +GET /api/v1/findings/{}/priority-explanation +GET /api/v1/groups/{}/scope-rules +GET /api/v1/groups/{}/scope-rules/{} +GET /api/v1/integrations/github/webhook-secret +GET /api/v1/integrations/jira/projects +GET /api/v1/integrations/jira/webhook-secret +GET /api/v1/invitations/{} +GET /api/v1/invitations/{}/preview +GET /api/v1/iocs +GET /api/v1/iocs/{} +GET /api/v1/me/permissions/sync +GET /api/v1/me/roles +GET /api/v1/module-presets +GET /api/v1/pentest/campaigns +GET /api/v1/pentest/campaigns/{} +GET /api/v1/pentest/campaigns/{}/findings +GET /api/v1/pentest/campaigns/{}/findings/export +GET /api/v1/pentest/campaigns/{}/members +GET /api/v1/pentest/campaigns/{}/reports +GET /api/v1/pentest/campaigns/{}/reports/download +GET /api/v1/pentest/campaigns/{}/stats +GET /api/v1/pentest/findings +GET /api/v1/pentest/findings/{} +GET /api/v1/pentest/findings/{}/retests +GET /api/v1/pentest/reports/{} +GET /api/v1/pentest/reports/{}/download +GET /api/v1/pentest/templates +GET /api/v1/pentest/templates/{} +GET /api/v1/permissions +GET /api/v1/permissions/modules +GET /api/v1/pipeline-runs +GET /api/v1/pipeline-runs/{} +GET /api/v1/pipelines +GET /api/v1/pipelines/{} +GET /api/v1/pipelines/{}/runs +GET /api/v1/platform/stats +GET /api/v1/priority-rules +GET /api/v1/priority-rules/{} +GET /api/v1/relationships/suggestions +GET /api/v1/relationships/suggestions/count +GET /api/v1/relationships/usage-stats +GET /api/v1/relationships/{} +GET /api/v1/remediation/campaigns +GET /api/v1/remediation/campaigns/{} +GET /api/v1/reports/schedules +GET /api/v1/reports/schedules/{} +GET /api/v1/repositories/{}/branches/compare +GET /api/v1/roles +GET /api/v1/roles/{} +GET /api/v1/roles/{}/members +GET /api/v1/scans/overview-stats +GET /api/v1/scim-tokens +GET /api/v1/scim-tokens/group-mappings +GET /api/v1/settings/identity-providers +GET /api/v1/settings/identity-providers/{} +GET /api/v1/settings/saml +GET /api/v1/settings/verified-domains +GET /api/v1/simulations +GET /api/v1/simulations/{} +GET /api/v1/simulations/{}/runs +GET /api/v1/suppressions +GET /api/v1/suppressions/active +GET /api/v1/suppressions/{} +GET /api/v1/tenants +GET /api/v1/tenants/{} +GET /api/v1/tenants/{}/invitations +GET /api/v1/tenants/{}/members +GET /api/v1/tenants/{}/members/stats +GET /api/v1/tenants/{}/settings +GET /api/v1/tenants/{}/settings/asset-identity +GET /api/v1/tenants/{}/settings/asset-lifecycle +GET /api/v1/tenants/{}/settings/asset-source +GET /api/v1/tenants/{}/settings/modules +GET /api/v1/tenants/{}/settings/modules/bundles +GET /api/v1/tenants/{}/settings/modules/graph +GET /api/v1/tenants/{}/settings/modules/presets +GET /api/v1/tenants/{}/settings/pentest +GET /api/v1/tenants/{}/settings/risk-scoring +GET /api/v1/tenants/{}/settings/risk-scoring/presets +GET /api/v1/threat-actors +GET /api/v1/threat-actors/{} +GET /api/v1/threat-intel/enrich/{} +GET /api/v1/threat-intel/epss/stats +GET /api/v1/threat-intel/epss/{} +GET /api/v1/threat-intel/kev/stats +GET /api/v1/threat-intel/kev/{} +GET /api/v1/threat-intel/stats +GET /api/v1/threat-intel/sync +GET /api/v1/threat-intel/sync/{} +GET /api/v1/threat-models +GET /api/v1/threat-models/{} +GET /api/v1/threat-models/{}/coverage +GET /api/v1/tool-categories +GET /api/v1/tool-categories/all +GET /api/v1/tool-categories/{} +GET /api/v1/users/{}/roles +GET /api/v1/validation/coverage +GET /api/v1/verification-checklists/{} +GET /api/v1/vulnerabilities/cve/{}/affected-assets +GET /api/v1/webhooks +GET /api/v1/webhooks/{} +GET /api/v1/webhooks/{}/deliveries +GET /api/v1/workflow-runs +GET /api/v1/workflow-runs/{} +GET /api/v1/workflows +GET /api/v1/workflows/{} +GET /api/v1/ws +PATCH /api/v1/admin/target-mappings/{} +PATCH /api/v1/admin/users/{} +PATCH /api/v1/assets/{}/crown-jewel +PATCH /api/v1/attachments/storage-config +PATCH /api/v1/control-tests/{}/result +PATCH /api/v1/pentest/campaigns/{}/members/{} +PATCH /api/v1/pentest/campaigns/{}/status +PATCH /api/v1/pentest/findings/{}/status +PATCH /api/v1/relationships/suggestions/{}/type +PATCH /api/v1/remediation/campaigns/{} +PATCH /api/v1/remediation/campaigns/{}/status +PATCH /api/v1/reports/schedules/{}/toggle +PATCH /api/v1/tenants/{} +PATCH /api/v1/tenants/{}/members/{} +PATCH /api/v1/tenants/{}/settings/api +PATCH /api/v1/tenants/{}/settings/asset-identity +PATCH /api/v1/tenants/{}/settings/branch +PATCH /api/v1/tenants/{}/settings/branding +PATCH /api/v1/tenants/{}/settings/general +PATCH /api/v1/tenants/{}/settings/modules +PATCH /api/v1/tenants/{}/settings/pentest +PATCH /api/v1/tenants/{}/settings/risk-scoring +PATCH /api/v1/tenants/{}/settings/security +PATCH /api/v1/threat-intel/sync/{} +POST /api/v1/admin/target-mappings +POST /api/v1/admin/users +POST /api/v1/admin/users/{}/rotate-key +POST /api/v1/agent/credentials/ingest +POST /api/v1/agent/ingest +POST /api/v1/agent/ingest/scan +POST /api/v1/agent/telemetry-events +POST /api/v1/api-keys +POST /api/v1/api-keys/{}/revoke +POST /api/v1/approvals/{}/approve +POST /api/v1/approvals/{}/cancel +POST /api/v1/approvals/{}/reject +POST /api/v1/assets/dedup/reviews/{}/approve +POST /api/v1/assets/dedup/reviews/{}/reject +POST /api/v1/assets/import/csv +POST /api/v1/assets/import/kubernetes +POST /api/v1/assets/import/nessus +POST /api/v1/assets/import/nessus-findings +POST /api/v1/assets/{}/lifecycle/snooze +POST /api/v1/assets/{}/owners +POST /api/v1/assets/{}/relationships +POST /api/v1/assets/{}/relationships/batch +POST /api/v1/assignment-rules +POST /api/v1/assignment-rules/{}/test +POST /api/v1/attachments +POST /api/v1/attachments/link +POST /api/v1/attacker-profiles +POST /api/v1/audit-logs/rebaseline +POST /api/v1/auth/backchannel-logout +POST /api/v1/auth/create-first-team +POST /api/v1/auth/saml/{}/acs +POST /api/v1/auth/sso/{}/callback +POST /api/v1/business-services +POST /api/v1/business-services/{}/assets +POST /api/v1/business-units +POST /api/v1/business-units/{}/assets +POST /api/v1/capabilities/usage-stats +POST /api/v1/compensating-controls +POST /api/v1/compensating-controls/{}/assets +POST /api/v1/compensating-controls/{}/findings +POST /api/v1/compensating-controls/{}/test +POST /api/v1/compliance/controls/{}/assess +POST /api/v1/compliance/findings/{}/controls +POST /api/v1/compliance/findings/{}/controls/auto-map +POST /api/v1/components/import +POST /api/v1/control-tests +POST /api/v1/ctem-cycles +POST /api/v1/ctem-cycles/{}/activate +POST /api/v1/ctem-cycles/{}/close +POST /api/v1/ctem-cycles/{}/profiles +POST /api/v1/ctem-cycles/{}/start-review +POST /api/v1/custom-capabilities +POST /api/v1/custom-tool-categories +POST /api/v1/findings/actions/assign-to-owners +POST /api/v1/findings/actions/fix-applied +POST /api/v1/findings/actions/reject-fix +POST /api/v1/findings/actions/verify +POST /api/v1/findings/ai-triage/bulk +POST /api/v1/findings/{}/ai-triage +POST /api/v1/findings/{}/approvals +POST /api/v1/findings/{}/comments +POST /api/v1/findings/{}/create-ticket +POST /api/v1/findings/{}/evidence +POST /api/v1/findings/{}/link-ticket +POST /api/v1/findings/{}/remediation/steps +POST /api/v1/findings/{}/request-verification +POST /api/v1/findings/{}/validate +POST /api/v1/groups/{}/assets/bulk +POST /api/v1/groups/{}/scope-rules +POST /api/v1/groups/{}/scope-rules/reconcile +POST /api/v1/groups/{}/scope-rules/{}/preview +POST /api/v1/integrations/defectdojo/sync +POST /api/v1/integrations/github/webhook-secret/rotate +POST /api/v1/integrations/jira/webhook-secret/rotate +POST /api/v1/integrations/{}/import-repositories +POST /api/v1/invitations/{}/accept +POST /api/v1/invitations/{}/accept-with-refresh +POST /api/v1/invitations/{}/decline +POST /api/v1/iocs +POST /api/v1/mcp +POST /api/v1/pentest/campaigns +POST /api/v1/pentest/campaigns/{}/findings +POST /api/v1/pentest/campaigns/{}/findings/import +POST /api/v1/pentest/campaigns/{}/members +POST /api/v1/pentest/campaigns/{}/reports +POST /api/v1/pentest/findings/{}/retests +POST /api/v1/pentest/templates +POST /api/v1/pipeline-runs/{}/cancel +POST /api/v1/pipelines +POST /api/v1/pipelines/{}/activate +POST /api/v1/pipelines/{}/clone +POST /api/v1/pipelines/{}/deactivate +POST /api/v1/pipelines/{}/runs +POST /api/v1/pipelines/{}/steps +POST /api/v1/priority-rules +POST /api/v1/relationships/suggestions/approve-all +POST /api/v1/relationships/suggestions/approve-batch +POST /api/v1/relationships/suggestions/generate +POST /api/v1/relationships/suggestions/{}/approve +POST /api/v1/relationships/suggestions/{}/dismiss +POST /api/v1/remediation/campaigns +POST /api/v1/remediation/campaigns/{}/create-ticket +POST /api/v1/remediation/campaigns/{}/refresh +POST /api/v1/remediation/campaigns/{}/resolve +POST /api/v1/reports/schedules +POST /api/v1/roles +POST /api/v1/roles/{}/members/bulk +POST /api/v1/scans/quick +POST /api/v1/scim-tokens +POST /api/v1/settings/identity-providers +POST /api/v1/settings/verified-domains +POST /api/v1/settings/verified-domains/{}/verify +POST /api/v1/simulations +POST /api/v1/simulations/{}/run +POST /api/v1/suppressions +POST /api/v1/suppressions/{}/approve +POST /api/v1/suppressions/{}/reject +POST /api/v1/template-sources/{}/sync +POST /api/v1/tenants +POST /api/v1/tenants/{}/invitations +POST /api/v1/tenants/{}/invitations/{}/resend +POST /api/v1/tenants/{}/members +POST /api/v1/tenants/{}/members/{}/reactivate +POST /api/v1/tenants/{}/members/{}/suspend +POST /api/v1/tenants/{}/settings/asset-lifecycle/dry-run +POST /api/v1/tenants/{}/settings/modules/bundles +POST /api/v1/tenants/{}/settings/modules/presets/{}/apply +POST /api/v1/tenants/{}/settings/modules/presets/{}/preview +POST /api/v1/tenants/{}/settings/modules/reset +POST /api/v1/tenants/{}/settings/modules/validate +POST /api/v1/tenants/{}/settings/risk-scoring/preview +POST /api/v1/tenants/{}/settings/risk-scoring/recalculate +POST /api/v1/threat-actors +POST /api/v1/threat-intel/enrich +POST /api/v1/threat-intel/sync +POST /api/v1/threat-models/generate +POST /api/v1/users/{}/roles +POST /api/v1/validation/evidence +POST /api/v1/webhooks +POST /api/v1/webhooks/incoming/github +POST /api/v1/webhooks/incoming/jira +POST /api/v1/webhooks/{}/disable +POST /api/v1/webhooks/{}/enable +POST /api/v1/workflow-runs/{}/cancel +POST /api/v1/workflows +POST /api/v1/workflows/{}/edges +POST /api/v1/workflows/{}/nodes +POST /api/v1/workflows/{}/runs +PUT /api/v1/assets/{}/owners/{} +PUT /api/v1/assignment-rules/{} +PUT /api/v1/attacker-profiles/{} +PUT /api/v1/business-services/{} +PUT /api/v1/business-units/{} +PUT /api/v1/compensating-controls/{} +PUT /api/v1/ctem-cycles/{} +PUT /api/v1/custom-capabilities/{} +PUT /api/v1/custom-tool-categories/{} +PUT /api/v1/findings/{}/comments/{} +PUT /api/v1/groups/{}/scope-rules/{} +PUT /api/v1/pentest/campaigns/{} +PUT /api/v1/pentest/findings/{} +PUT /api/v1/pentest/templates/{} +PUT /api/v1/pipelines/{} +PUT /api/v1/pipelines/{}/steps/{} +PUT /api/v1/priority-rules/{} +PUT /api/v1/relationships/{} +PUT /api/v1/roles/{} +PUT /api/v1/scim-tokens/group-mappings +PUT /api/v1/settings/identity-providers/{} +PUT /api/v1/settings/saml +PUT /api/v1/simulations/{} +PUT /api/v1/suppressions/{} +PUT /api/v1/tenants/{}/settings/asset-lifecycle +PUT /api/v1/tenants/{}/settings/asset-source +PUT /api/v1/users/{}/roles +PUT /api/v1/verification-checklists/{} +PUT /api/v1/webhooks/{} +PUT /api/v1/workflows/{} +PUT /api/v1/workflows/{}/graph +PUT /api/v1/workflows/{}/nodes/{} diff --git a/cmd/server/repositories.go b/cmd/server/repositories.go index 0028bdfd..bca71c8a 100644 --- a/cmd/server/repositories.go +++ b/cmd/server/repositories.go @@ -183,6 +183,10 @@ type Repositories struct { // Validation evidence (CTEM Stage-4, migration 000178) ValidationEvidence *postgres.ValidationEvidenceRepository + // Runtime-telemetry reads for Stage-4 detection correlation + // (migration 000203) + TelemetryProbe *postgres.TelemetryProbeRepository + // SCIM provisioning bearer tokens (RFC-009, migration 000179) ScimToken *postgres.ScimTokenRepository @@ -369,6 +373,9 @@ func NewRepositories(db *postgres.DB) *Repositories { // Validation evidence (CTEM Stage-4, migration 000178). ValidationEvidence: postgres.NewValidationEvidenceRepository(db), + // Runtime-telemetry reads for detection correlation (migration 000203). + TelemetryProbe: postgres.NewTelemetryProbeRepository(db), + // SCIM provisioning bearer tokens (RFC-009, migration 000179). ScimToken: postgres.NewScimTokenRepository(db), diff --git a/cmd/server/services.go b/cmd/server/services.go index 80d3b394..73d7b83e 100644 --- a/cmd/server/services.go +++ b/cmd/server/services.go @@ -808,6 +808,14 @@ func NewServices(deps *ServiceDeps) (*Services, error) { // Validation (CTEM Stage-4): agents POST proof-of-fix / technique evidence, // which is persisted (redacted) and reconciled into finding status. evidenceStore := validation.NewEvidenceStore(repos.ValidationEvidence) + // Stage-4's second question: "did our controls react?". Correlates + // the tenant's runtime telemetry against each validation's execution + // window. Reports no_telemetry_source (an explicit UNKNOWN) when no + // telemetry is reaching the platform at all, so a missing EDR/XDR + // integration is never rendered as a failed control. + evidenceStore.SetDetectionCorrelator( + validation.NewDetectionCorrelator(repos.TelemetryProbe), + ) s.ValidationEvidence = validation.NewEvidenceIngestService( evidenceStore, findingMutatorAdapter{repo: repos.Finding}, diff --git a/go.sum b/go.sum index 86bc3f08..15d23b57 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,26 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= +cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.43.2 h1:cl+IXwWb3qazClUcm08tGSsB6OiuV83JVJO9B0jQcPc= @@ -49,16 +63,23 @@ github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= @@ -68,10 +89,20 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= @@ -88,6 +119,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -107,6 +140,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -127,6 +162,12 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= @@ -136,6 +177,7 @@ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -148,16 +190,26 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/openctemio/ctis v1.1.0 h1:yGvyolD/bir1WO6uCEIPK6jgSoa0ZY1um/GxhnM074Q= github.com/openctemio/ctis v1.1.0/go.mod h1:2JPNuKKM8Se951SQ2dqNSzf+0wyFJe3tEm1BGd2zbDI= +github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ= +github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -177,14 +229,21 @@ github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93 github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -193,6 +252,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -205,16 +266,22 @@ github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrI github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= @@ -237,6 +304,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= @@ -250,6 +318,8 @@ golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -260,6 +330,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= @@ -288,6 +360,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/app/audit/service.go b/internal/app/audit/service.go index a6f2c2b9..df6aedce 100644 --- a/internal/app/audit/service.go +++ b/internal/app/audit/service.go @@ -201,7 +201,17 @@ func (s *AuditService) VerifyChain(ctx context.Context, tenantID shared.ID, limi // 1. Fetch the original audit_log. If it's gone, flag it — a // deleted row is a tamper signal (FK ON DELETE RESTRICT // blocks it in production but not in every path). - log, err := s.auditRepo.GetByTenantAndID(ctx, tenantID, e.AuditLogID) + // System-chain entries point at audit_logs rows with tenant_id IS NULL, + // which the tenant-scoped getter cannot see — using it here would + // report every one of them as audit_log_missing, i.e. a fabricated + // tamper signal on the chain that exists to detect real ones. + var log *auditdom.AuditLog + var err error + if tenantID == auditdom.SystemChainTenantID { + log, err = s.auditRepo.GetSystemByID(ctx, e.AuditLogID) + } else { + log, err = s.auditRepo.GetByTenantAndID(ctx, tenantID, e.AuditLogID) + } if err != nil { res.Breaks = append(res.Breaks, ChainBreak{ AuditLogID: e.AuditLogID.String(), @@ -319,11 +329,15 @@ func (s *AuditService) RebaselineChain(ctx context.Context, tenantID shared.ID, // (pg_advisory_xact_lock) and is not wired here because the current // deployment is single-replica. func (s *AuditService) appendChainEntry(ctx context.Context, log *auditdom.AuditLog) { - tenantPtr := log.TenantID() - if tenantPtr == nil { - return // system-level events bypass the per-tenant chain + // Tenant-less events (every auth.login / auth.register / auth.failed — + // 86% of the trail on the live database) used to return here, which left + // them with no tamper evidence at all. They now extend a dedicated system + // chain instead. See auditdom.SystemChainTenantID for why a sentinel + // rather than a nullable column. + tid := auditdom.SystemChainTenantID + if tenantPtr := log.TenantID(); tenantPtr != nil { + tid = *tenantPtr } - tid := *tenantPtr s.chainMu.Lock() defer s.chainMu.Unlock() diff --git a/internal/app/audit/system_chain_db_test.go b/internal/app/audit/system_chain_db_test.go new file mode 100644 index 00000000..4cb2761d --- /dev/null +++ b/internal/app/audit/system_chain_db_test.go @@ -0,0 +1,182 @@ +package audit_test + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/lib/pq" + + auditapp "github.com/openctemio/api/internal/app/audit" + "github.com/openctemio/api/internal/infra/postgres" + auditdom "github.com/openctemio/api/pkg/domain/audit" + "github.com/openctemio/api/pkg/logger" +) + +// package audit_test, not audit: this test needs the real postgres repository, +// and internal/infra/postgres -> internal/app -> internal/app/audit, so an +// in-package test would be an import cycle. An external test package can +// depend on packages that depend on the one under test. +// +// Driven through the real repository against a real database, because the +// question this answers is not "does the Go branch take the right path" but +// "does a row land in audit_log_chain". A mock would answer the first and +// prove nothing about the second — and the whole reason this gap existed for +// months is that the components were each individually correct. + +func openAuditDB(t *testing.T) *postgres.DB { + t.Helper() + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + t.Skip("DATABASE_URL not set; skipping audit system-chain DB tests") + } + db, err := sql.Open("postgres", dbURL) + if err != nil { + t.Skipf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.PingContext(context.Background()); err != nil { + t.Skipf("cannot reach DATABASE_URL: %v", err) + } + return &postgres.DB{DB: db} +} + +// logSystemAuthEvent writes one tenant-less auth event the way the production +// helper does, and returns its audit_log id. +func logSystemAuthEvent(ctx context.Context, t *testing.T, db *postgres.DB) string { + t.Helper() + + repo := postgres.NewAuditRepository(db) + svc := auditapp.NewAuditService(repo, logger.NewNop()) + + // No TenantID: this is what every auth.login carries. + if err := svc.LogUserLogin(ctx, auditapp.AuditContext{}, "", "chain-test@example.test"); err != nil { + t.Fatalf("log login event: %v", err) + } + + var id string + if err := db.QueryRowContext(ctx, ` + SELECT id FROM audit_logs + WHERE tenant_id IS NULL AND resource_name = $1 + ORDER BY logged_at DESC LIMIT 1`, + "chain-test@example.test").Scan(&id); err != nil { + t.Fatalf("read back the audit log: %v", err) + } + t.Cleanup(func() { + bg := context.Background() + _, _ = db.ExecContext(bg, `DELETE FROM audit_log_chain WHERE audit_log_id = $1`, id) + _, _ = db.ExecContext(bg, `DELETE FROM audit_logs WHERE id = $1`, id) + }) + return id +} + +// The defect: an auth event produced no chain row at all, so deleting or +// editing it left no evidence. +func TestSystemChain_AuthEventIsChained(t *testing.T) { + ctx := context.Background() + db := openAuditDB(t) + + id := logSystemAuthEvent(ctx, t, db) + + var tenantID, hash string + err := db.QueryRowContext(ctx, + `SELECT tenant_id, hash FROM audit_log_chain WHERE audit_log_id = $1`, id, + ).Scan(&tenantID, &hash) + if err != nil { + t.Fatalf("no chain row for a tenant-less auth event (%v). It is stored in "+ + "audit_logs with no tamper evidence: an intruder can delete the record "+ + "of their own login and the verifier will report the trail intact, "+ + "because it only walks rows that were chained", err) + } + + if tenantID != auditdom.SystemChainTenantID.String() { + t.Errorf("chain row tenant_id = %s, want the system chain sentinel %s", + tenantID, auditdom.SystemChainTenantID) + } + if len(hash) != 64 { + t.Errorf("hash = %q, want 64 hex chars", hash) + } +} + +// A chain is only evidence if verification agrees with what was written. This +// catches a write/verify payload mismatch — the failure mode that produced +// months of false "chain break" alerts once before. +func TestSystemChain_VerifiesClean(t *testing.T) { + ctx := context.Background() + db := openAuditDB(t) + + id := logSystemAuthEvent(ctx, t, db) + + repo := postgres.NewAuditRepository(db) + svc := auditapp.NewAuditService(repo, logger.NewNop()) + + res, err := svc.VerifyChain(ctx, auditdom.SystemChainTenantID, 10_000) + if err != nil { + t.Fatalf("VerifyChain: %v", err) + } + if res.Total == 0 { + t.Fatal("the system chain verified 0 entries: nothing is being checked") + } + + for _, b := range res.Breaks { + if b.AuditLogID == id { + t.Fatalf("the entry just written verifies as broken (%s). Note "+ + "audit_log_missing here means the verifier looked the row up with "+ + "the tenant-scoped getter, which cannot see tenant_id IS NULL rows "+ + "— a fabricated tamper signal on the chain that exists to detect "+ + "real ones", b.Reason) + } + } +} + +// Tenant-scoped events must keep going to their own chain. A fix that swept +// everything into the system chain would destroy per-tenant isolation of the +// audit trail. +func TestSystemChain_TenantEventsStillUseTheirOwnChain(t *testing.T) { + ctx := context.Background() + db := openAuditDB(t) + + var tenantID string + if err := db.QueryRowContext(ctx, + `INSERT INTO tenants (id, name, slug) + VALUES (gen_random_uuid(), 'chain test', 'chain-test-' || gen_random_uuid()) + RETURNING id`).Scan(&tenantID); err != nil { + t.Fatalf("seed tenant: %v", err) + } + t.Cleanup(func() { + bg := context.Background() + _, _ = db.ExecContext(bg, + `DELETE FROM audit_log_chain WHERE audit_log_id IN + (SELECT id FROM audit_logs WHERE tenant_id = $1)`, tenantID) + _, _ = db.ExecContext(bg, `DELETE FROM audit_logs WHERE tenant_id = $1`, tenantID) + _, _ = db.ExecContext(bg, `DELETE FROM tenants WHERE id = $1`, tenantID) + }) + + repo := postgres.NewAuditRepository(db) + svc := auditapp.NewAuditService(repo, logger.NewNop()) + + if err := svc.LogUserLogin(ctx, auditapp.AuditContext{TenantID: tenantID}, + "", "tenant-scoped@example.test"); err != nil { + t.Fatalf("log tenant event: %v", err) + } + + var chainTenant string + if err := db.QueryRowContext(ctx, ` + SELECT c.tenant_id + FROM audit_log_chain c + JOIN audit_logs l ON l.id = c.audit_log_id + WHERE l.tenant_id = $1 + ORDER BY c.chain_position DESC LIMIT 1`, tenantID).Scan(&chainTenant); err != nil { + t.Fatalf("no chain row for a tenant-scoped event: %v", err) + } + + if chainTenant == auditdom.SystemChainTenantID.String() { + t.Fatal("a tenant's audit event was appended to the SYSTEM chain, merging " + + "tenants' trails into one shared chain") + } + if chainTenant != tenantID { + t.Errorf("chain tenant = %s, want %s", chainTenant, tenantID) + } +} diff --git a/internal/app/command/expiration_db_test.go b/internal/app/command/expiration_db_test.go new file mode 100644 index 00000000..3803b7d0 --- /dev/null +++ b/internal/app/command/expiration_db_test.go @@ -0,0 +1,246 @@ +package command + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "testing" + "time" + + _ "github.com/lib/pq" + + "github.com/openctemio/api/internal/infra/postgres" + commanddom "github.com/openctemio/api/pkg/domain/command" + "github.com/openctemio/api/pkg/domain/shared" + "github.com/openctemio/api/pkg/logger" +) + +// ExpirationChecker has run on a 60s tick in every deployment and had never +// expired a single command. Both consumers of commands.expires_at require +// `expires_at IS NOT NULL`: +// +// FindExpired: WHERE status IN ('pending','acknowledged') +// AND expires_at IS NOT NULL AND expires_at < NOW() +// GetPendingForAgent: AND (expires_at IS NULL OR expires_at > NOW()) +// +// and nothing ever wrote the column: the only setter was +// command.Service.Create's `if input.ExpiresIn > 0`, and no caller passes +// ExpiresIn. All six creation sites (scan/trigger x2, pipeline/run, +// validation/dispatcher, scancoverage/dispatcher, command/service) went through +// commanddom.NewCommand, which left ExpiresAt nil. On the live database: +// 21 commands, 0 with an expiry, 0 ever expired. +// +// So a command nobody answers was never expired and +// pipeline.OnStepFailed(..., "COMMAND_EXPIRED") had never fired — the owning run +// hung until ScanTimeoutController reported a generic timeout instead. +// +// These tests go through the real postgres repository so they fail if the +// default stops being written, or is written somewhere FindExpired cannot see. + +func openCommandDB(t *testing.T) *postgres.DB { + t.Helper() + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + t.Skip("DATABASE_URL not set; skipping command expiry DB tests") + } + + sqlDB, err := sql.Open("postgres", dbURL) + if err != nil { + t.Skipf("open db: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + + if err := sqlDB.PingContext(context.Background()); err != nil { + t.Skipf("cannot reach DATABASE_URL: %v", err) + } + return &postgres.DB{DB: sqlDB} +} + +// seedExpiryTenant creates a throwaway tenant; commands.tenant_id is a foreign +// key, so a random shared.NewID() fails the insert. +func seedExpiryTenant(ctx context.Context, t *testing.T, db *postgres.DB) shared.ID { + t.Helper() + + id := shared.NewID() + _, err := db.ExecContext(ctx, + `INSERT INTO tenants (id, name, slug) VALUES ($1, $2, $3)`, + id.String(), "command expiry test", "test-"+id.String()) + if err != nil { + t.Fatalf("seed tenant: %v", err) + } + t.Cleanup(func() { + _, _ = db.ExecContext(context.Background(), + `DELETE FROM tenants WHERE id = $1`, id.String()) + }) + return id +} + +func pipelinePayload(t *testing.T, runID, stepKey string) json.RawMessage { + t.Helper() + + raw, err := json.Marshal(map[string]string{ + "pipeline_run_id": runID, + "step_key": stepKey, + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return raw +} + +// TestCommandCreation_PersistsDefaultExpiry is the direct regression: a command +// built the way production builds them must reach the database with a non-NULL +// expires_at. Before the fix the column was NULL for every row. +func TestCommandCreation_PersistsDefaultExpiry(t *testing.T) { + ctx := context.Background() + db := openCommandDB(t) + repo := postgres.NewCommandRepository(db) + tenantID := seedExpiryTenant(ctx, t, db) + + svc := NewService(repo, logger.NewNop()) + + created, err := svc.Create(ctx, CreateInput{ + TenantID: tenantID.String(), + Type: string(commanddom.CommandTypeScan), + Payload: pipelinePayload(t, shared.NewID().String(), "default-expiry-step"), + }) + if err != nil { + t.Fatalf("create command: %v", err) + } + + // Read the persisted row back, not the in-memory entity. + var expiresAt sql.NullTime + if err := db.QueryRowContext(ctx, + `SELECT expires_at FROM commands WHERE id = $1`, created.ID.String(), + ).Scan(&expiresAt); err != nil { + t.Fatalf("read back command: %v", err) + } + + if !expiresAt.Valid { + t.Fatal("commands.expires_at is NULL for a command created through the " + + "production path: FindExpired requires `expires_at IS NOT NULL`, so " + + "ExpirationChecker can never see this row and the owning pipeline run " + + "will never receive COMMAND_EXPIRED") + } + + ttl := time.Until(expiresAt.Time) + // Generous window: this asserts the default is the intended backstop + // magnitude, not a stopwatch. + if ttl < commanddom.DefaultCommandTTL-time.Hour || ttl > commanddom.DefaultCommandTTL+time.Hour { + t.Fatalf("expires_at is %v away, want ~%v (DefaultCommandTTL); a shorter "+ + "default would expire healthy in-flight work before the timeouts that "+ + "should catch it first", ttl, commanddom.DefaultCommandTTL) + } +} + +// TestCommandCreation_ExplicitExpiresInWins pins that the default is a fallback: +// an explicit ExpiresIn must still take effect. +func TestCommandCreation_ExplicitExpiresInWins(t *testing.T) { + ctx := context.Background() + db := openCommandDB(t) + repo := postgres.NewCommandRepository(db) + tenantID := seedExpiryTenant(ctx, t, db) + + svc := NewService(repo, logger.NewNop()) + + created, err := svc.Create(ctx, CreateInput{ + TenantID: tenantID.String(), + Type: string(commanddom.CommandTypeScan), + Payload: pipelinePayload(t, shared.NewID().String(), "explicit-expiry-step"), + ExpiresIn: 300, + }) + if err != nil { + t.Fatalf("create command: %v", err) + } + + var expiresAt sql.NullTime + if err := db.QueryRowContext(ctx, + `SELECT expires_at FROM commands WHERE id = $1`, created.ID.String(), + ).Scan(&expiresAt); err != nil { + t.Fatalf("read back command: %v", err) + } + if !expiresAt.Valid { + t.Fatal("expires_at is NULL despite an explicit ExpiresIn") + } + + if ttl := time.Until(expiresAt.Time); ttl > time.Hour { + t.Fatalf("expires_at is %v away: the 5 minute ExpiresIn was overwritten by "+ + "the %v default", ttl, commanddom.DefaultCommandTTL) + } +} + +// TestExpirationChecker_ExpiresCommandAndFailsStep drives the real checker over +// the real repository: create through the production path, move the row's clock +// past its own deadline, and assert the checker both marks it expired and tells +// the owning run with COMMAND_EXPIRED. +// +// The clock shift is relative (`expires_at = expires_at - interval`), never an +// absolute timestamp: `NULL - interval` is NULL, so this test cannot pass by +// injecting the value the code under test was supposed to write. +func TestExpirationChecker_ExpiresCommandAndFailsStep(t *testing.T) { + ctx := context.Background() + db := openCommandDB(t) + repo := postgres.NewCommandRepository(db) + tenantID := seedExpiryTenant(ctx, t, db) + + runID := shared.NewID().String() + const stepKey = "expiry-backstop-step" + + svc := NewService(repo, logger.NewNop()) + created, err := svc.Create(ctx, CreateInput{ + TenantID: tenantID.String(), + Type: string(commanddom.CommandTypeScan), + Payload: pipelinePayload(t, runID, stepKey), + }) + if err != nil { + t.Fatalf("create command: %v", err) + } + + shiftSeconds := int64((commanddom.DefaultCommandTTL + time.Hour).Seconds()) + res, err := db.ExecContext(ctx, + `UPDATE commands SET expires_at = expires_at - ($2 || ' seconds')::INTERVAL WHERE id = $1`, + created.ID.String(), shiftSeconds) + if err != nil { + t.Fatalf("shift expiry: %v", err) + } + if n, _ := res.RowsAffected(); n != 1 { + t.Fatalf("shift expiry updated %d rows, want 1", n) + } + + failer := &stubStepFailer{} + checker := NewExpirationChecker(repo, nil, ExpirationCheckerConfig{}, logger.NewNop()) + checker.pipelineService = failer + + checker.checkAndExpire() + + var status string + if err := db.QueryRowContext(ctx, + `SELECT status FROM commands WHERE id = $1`, created.ID.String(), + ).Scan(&status); err != nil { + t.Fatalf("read back status: %v", err) + } + if status != string(commanddom.CommandStatusExpired) { + t.Fatalf("command status = %q, want %q: FindExpired did not match a command "+ + "that is past its own expires_at", status, commanddom.CommandStatusExpired) + } + + var got *recordedStepFailure + for i := range failer.calls { + if failer.calls[i].runID == runID { + got = &failer.calls[i] + break + } + } + if got == nil { + t.Fatalf("pipeline run %s was not notified: the command expired silently and "+ + "the run is left waiting on a step that is already dead", runID) + } + if got.stepKey != stepKey { + t.Errorf("step key = %q, want %q", got.stepKey, stepKey) + } + if got.code != expiryReasonCommand.code { + t.Errorf("error code = %q, want %q", got.code, expiryReasonCommand.code) + } +} diff --git a/internal/app/command/service.go b/internal/app/command/service.go index dd60d466..53e84db6 100644 --- a/internal/app/command/service.go +++ b/internal/app/command/service.go @@ -335,8 +335,3 @@ func (s *Service) DeleteCommand(ctx context.Context, tenantID, commandID string) return s.repo.Delete(ctx, cid) } - -// ExpireOldCommands expires old pending commands. -func (s *Service) ExpireOldCommands(ctx context.Context) (int64, error) { - return s.repo.ExpireOldCommands(ctx) -} diff --git a/internal/app/compliance/simulation.go b/internal/app/compliance/simulation.go index 2a495c9c..4768ffa7 100644 --- a/internal/app/compliance/simulation.go +++ b/internal/app/compliance/simulation.go @@ -434,6 +434,17 @@ func (s *SimulationService) FinalizeRun(ctx context.Context, tenantID, runID sha "verified": true, "outcome": outcome, "summary": summary, + // A live safe-check measures REACHABILITY. It does not observe a + // control reacting, so this run carries no detection verdict. + // The Phase-0 synthetic path already flagged this; the Phase-1b + // live path did not, and its detection/prevention strings read + // as if a control had been graded. Whether anything detected the + // activity is answered separately by + // validation_evidence.detection_status. + "detection_validated": false, + "disclaimer": "Live reachability probe only. 'detected'/'bypassed' here describe whether the " + + "target was reachable, NOT whether a security control observed the activity. See " + + "validation_evidence.detection_status for the detection verdict.", } run.Complete(result, detection, prevention, output) if err := s.runRepo.Update(ctx, run); err != nil { @@ -453,6 +464,12 @@ func (s *SimulationService) FinalizeRun(ctx context.Context, tenantID, runID sha return nil } +// detectionNotAssessed is the detection string for every LIVE safe-check +// result. A reachability probe never observes a control reacting, so the +// run must not carry a detection verdict — that question is answered by +// validation_evidence.detection_status (see app/validation/detection.go). +const detectionNotAssessed = "Detection not assessed — live safe-check measures reachability only" + // mapOutcomeToResult translates a validation safe-check outcome (reachability // semantics) into a simulation run result: // - not_detected → prevented (target unreachable; control/segmentation held) @@ -461,21 +478,25 @@ func (s *SimulationService) FinalizeRun(ctx context.Context, tenantID, runID sha // - error/other → error func mapOutcomeToResult(outcome string) (result simulation.RunResult, detection, prevention string) { switch outcome { + // The detection string is detectionNotAssessed on every branch: a + // reachability probe cannot tell whether a control saw anything, and + // phrasing it as a detection result made the UI's detection-rate KPI + // read as a graded control outcome. case "not_detected": return simulation.RunResultPrevented, - "Live safe-check: target not reachable", - "Reachability control held — technique path closed" + detectionNotAssessed, + "Target not reachable — technique path closed (reachability control held)" case "detected": return simulation.RunResultBypassed, - "Live safe-check: target reachable", + detectionNotAssessed, "Target reachable — technique path is open" case "inconclusive": return simulation.RunResultPartial, - "Live safe-check: inconclusive", - "Partial signal" + detectionNotAssessed, + "Reachability inconclusive — partial signal" default: return simulation.RunResultError, - "Live safe-check: error", + "Detection not assessed — probe did not complete", "Probe did not complete" } } diff --git a/internal/app/ingest/audit_chain_test.go b/internal/app/ingest/audit_chain_test.go index 61484399..dd21babf 100644 --- a/internal/app/ingest/audit_chain_test.go +++ b/internal/app/ingest/audit_chain_test.go @@ -95,6 +95,10 @@ func (r *chainAuditRepo) GetByTenantAndID(_ context.Context, tenantID, id shared return log, nil } +func (r *chainAuditRepo) GetSystemByID(_ context.Context, id shared.ID) (*audit.AuditLog, error) { + return nil, audit.AuditLogNotFoundError(id) +} + // chainEntryFor returns the chain row covering an audit log, if any. func (r *chainAuditRepo) chainEntryFor(id shared.ID) (audit.ChainEntry, bool) { r.mu.Lock() diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 0557f90c..b72c3a72 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -2020,8 +2020,12 @@ type SendNotificationInput struct { Title string Body string Severity string // critical, high, medium, low - URL string - Fields map[string]string + // EventType decides whether Severity is filterable at all. Empty means + // "unknown", and SeverityFilterApplies treats unknown as filterable — the + // pre-existing behavior for every caller that does not set it. + EventType string + URL string + Fields map[string]string } // SendNotificationResult represents the result of sending a notification. @@ -2072,8 +2076,10 @@ func (s *IntegrationService) SendNotification(ctx context.Context, input SendNot notifExt, _ = s.notificationExtRepo.GetByIntegrationID(ctx, intgID) } - // Check if we should notify for this severity - if notifExt != nil && !notifExt.ShouldNotify(input.Severity) { + // Check if we should notify for this severity. Skipped for event types whose + // Severity is not a finding severity — see integrationdom.SeverityFilterApplies. + if notifExt != nil && integrationdom.SeverityFilterApplies(integrationdom.EventType(input.EventType)) && + !notifExt.ShouldNotify(input.Severity) { return &SendNotificationResult{ Success: false, Error: fmt.Sprintf("notifications disabled for severity: %s", input.Severity), @@ -2161,8 +2167,11 @@ func (s *IntegrationService) BroadcastNotification(ctx context.Context, input Br } if iwn.Notification != nil { - // Check if this integration should receive notifications for this severity - if !iwn.Notification.ShouldNotify(input.Severity) { + // Check if this integration should receive notifications for this + // severity. Not applied to event types whose Severity is a constant + // rather than a finding severity — see SeverityFilterApplies. + if integrationdom.SeverityFilterApplies(input.EventType) && + !iwn.Notification.ShouldNotify(input.Severity) { continue } @@ -2178,6 +2187,7 @@ func (s *IntegrationService) BroadcastNotification(ctx context.Context, input Br Title: input.Title, Body: input.Body, Severity: input.Severity, + EventType: string(input.EventType), URL: input.URL, Fields: input.Fields, }) diff --git a/internal/app/validation/detection.go b/internal/app/validation/detection.go new file mode 100644 index 00000000..f587fbdd --- /dev/null +++ b/internal/app/validation/detection.go @@ -0,0 +1,287 @@ +package validation + +import ( + "context" + "time" + + "github.com/openctemio/api/pkg/domain/shared" +) + +// Detection correlation — CTEM Stage-4's second question. +// +// A validation run answers "is the exposure still reachable?" via +// Outcome. It says NOTHING about whether the defensive stack noticed. +// DetectionStatus answers that second question by correlating the +// tenant's runtime telemetry against the window in which the +// validation actually ran. +// +// --------------------------------------------------------------- +// Why a separate vocabulary from Outcome +// --------------------------------------------------------------- +// Outcome's words 'detected' / 'not_detected' describe the TARGET: +// 'detected' means the probe still reached it (bad news). A detection +// verdict describes our SENSORS: seeing the probe is good news. Same +// words, opposite subject AND opposite polarity. Overloading them +// would make every stored row ambiguous about which question it +// answers, and no amount of documentation fixes an ambiguous datum. +// The two enums below and in Outcome share no member. +// +// --------------------------------------------------------------- +// Why absence of telemetry is not a failure +// --------------------------------------------------------------- +// The single most dangerous thing this feature could do is report +// "nothing detected your attack" when the truth is "no telemetry +// source is connected to this platform". The first is a security +// finding; the second is a configuration gap. They are reported as +// DIFFERENT statuses (DetectionNotObserved vs +// DetectionNoTelemetrySource) and the correlator establishes pipeline +// liveness BEFORE it is willing to say anything negative about a +// control. + +// DetectionStatus is the verdict on whether anything observed a +// validation. Deliberately disjoint from Outcome — see file header. +type DetectionStatus string + +const ( + // DetectionObserved: telemetry correlated to this validation + // arrived. Something in the stack saw it. + DetectionObserved DetectionStatus = "observed" + + // DetectionNotObserved: the tenant IS shipping telemetry, but none + // of it correlated to this validation. This is the only value that + // asserts a real detection gap. + DetectionNotObserved DetectionStatus = "not_observed" + + // DetectionNoTelemetrySource: no telemetry is reaching the platform + // for this tenant at all. The honest reading is UNKNOWN — we did + // not look at a control, so we cannot grade one. + DetectionNoTelemetrySource DetectionStatus = "no_telemetry_source" + + // DetectionNotApplicable: the validation did not execute (error or + // skipped), so there was no attack to detect. Grading detection + // here would punish a control for our own failure to run. + DetectionNotApplicable DetectionStatus = "not_applicable" + + // DetectionNotEvaluated: correlation did not run for this record + // (probe unwired, or the row predates this feature). Never means + // "no detection". + DetectionNotEvaluated DetectionStatus = "not_evaluated" +) + +// IsDetectionGap reports whether the status asserts a genuine control +// failure. Only DetectionNotObserved qualifies. Dashboards MUST use +// this rather than `!= observed`, or "no telemetry configured" gets +// rendered as "controls failed" — the exact misreport this feature +// exists to prevent. +func (d DetectionStatus) IsDetectionGap() bool { return d == DetectionNotObserved } + +// IsConclusive reports whether the verdict is based on an actual look +// at telemetry. no_telemetry_source / not_applicable / not_evaluated +// are all "we did not or could not look". +func (d DetectionStatus) IsConclusive() bool { + return d == DetectionObserved || d == DetectionNotObserved +} + +// ValidDetectionStatus mirrors the DB CHECK constraint. +func ValidDetectionStatus(d DetectionStatus) bool { + switch d { + case DetectionObserved, DetectionNotObserved, DetectionNoTelemetrySource, + DetectionNotApplicable, DetectionNotEvaluated: + return true + } + return false +} + +// Correlation window. +// +// postWindow — how long AFTER the probe ends we keep accepting +// telemetry as caused by it. 5 minutes: EDR/XDR agents batch and +// forward on a timer (CrowdStrike/Defender/osquery forwarders are +// typically seconds to ~2 min; SIEM relay adds more), so anything +// shorter turns normal pipeline latency into fabricated detection +// gaps. Longer buys little: a sensor that takes >5 min is not +// providing actionable detection anyway. +// +// preGrace — how long BEFORE the recorded start we accept. Small (30s) +// and exists only to absorb clock skew between the agent host and the +// API, plus the gap between the command being issued and the probe +// firing. +// +// Failure modes, stated plainly: +// - FALSE NEGATIVE (reported not_observed though a control did fire): +// telemetry that arrives later than postWindow, e.g. an hourly +// batch forwarder. Mitigated by correlation_id, which is +// time-independent — a stamped event matches whenever it lands, +// as long as it lands before this evaluation runs. +// - FALSE POSITIVE (reported observed though nothing detected us): +// unrelated telemetry on the same asset inside the window. This is +// the real risk of time-based matching on a busy host. Mitigated +// two ways: (a) heuristic matching is restricted to event types a +// network probe could plausibly cause, and (b) every verdict +// records its match_mode, so an "observed" reached heuristically +// is auditable and never silently equated with an exact match. +const ( + DetectionPostWindow = 5 * time.Minute + DetectionPreGrace = 30 * time.Second + + // telemetryLivenessLookback — how far back we look to decide the + // pipeline is alive. 24h tolerates a quiet night on a small estate + // without declaring the pipeline dead. + telemetryLivenessLookback = 24 * time.Hour +) + +// heuristicEventTypes are the runtime_telemetry_events types a remote +// network safe-check could plausibly produce on the target. Restricting +// the time-window fallback to these keeps unrelated host noise +// (file_write, process_stop, kernel_module_load...) from being read as +// a detection. Exact correlation_id matching is NOT restricted. +var heuristicEventTypes = []string{"network_connect", "auth_attempt"} + +// TelemetryProbe is the read side of the telemetry stream that the +// correlator needs. Implemented by postgres; faked in tests. +type TelemetryProbe interface { + // PipelineLive reports whether ANY telemetry (of any kind) has + // arrived for the tenant since `since`. This is the guard that + // separates "no detection" from "no telemetry pipeline". + PipelineLive(ctx context.Context, tenantID shared.ID, since time.Time) (bool, error) + + // CountByCorrelationID counts events a producer explicitly stamped + // with this validation's correlation id. Exact; no time bounds. + CountByCorrelationID(ctx context.Context, tenantID, correlationID shared.ID) (int, error) + + // CountNearTarget counts events on the given asset between from and + // to whose event_type is in eventTypes. The heuristic fallback for + // producers that cannot stamp a correlation id. + CountNearTarget(ctx context.Context, tenantID, assetID shared.ID, from, to time.Time, eventTypes []string) (int, error) +} + +// DetectionCorrelator turns a completed validation plus the telemetry +// stream into a DetectionStatus. Safe to construct with a nil probe — +// it then reports DetectionNotEvaluated rather than guessing. +type DetectionCorrelator struct { + probe TelemetryProbe + now func() time.Time +} + +// NewDetectionCorrelator wires the correlator. probe may be nil, in +// which case every verdict is DetectionNotEvaluated. +func NewDetectionCorrelator(probe TelemetryProbe) *DetectionCorrelator { + return &DetectionCorrelator{probe: probe, now: func() time.Time { return time.Now().UTC() }} +} + +// DetectionVerdict is the correlator's output: the status plus enough +// detail for an operator to audit how it was reached. +type DetectionVerdict struct { + Status DetectionStatus + Detail map[string]any +} + +// Evaluate decides whether anything observed the validation described +// by ev. correlationID may be zero (no producer stamped anything). +// +// Order of checks is deliberate and is the safety property of this +// function: we refuse to say "not_observed" until we have positively +// established that telemetry is flowing for this tenant. Every earlier +// return is a form of "we don't know", never a control indictment. +func (c *DetectionCorrelator) Evaluate( + ctx context.Context, + tenantID shared.ID, + ev Evidence, + correlationID shared.ID, +) DetectionVerdict { + detail := map[string]any{} + + // 1. Nothing ran → nothing could be detected. + if ev.Outcome == OutcomeError || ev.Outcome == OutcomeSkipped { + detail["reason"] = "validation did not execute (outcome=" + string(ev.Outcome) + ")" + return DetectionVerdict{Status: DetectionNotApplicable, Detail: detail} + } + + // 2. Correlation not wired → we did not look. Not a gap. + if c.probe == nil || tenantID.IsZero() { + detail["reason"] = "detection correlation not configured" + return DetectionVerdict{Status: DetectionNotEvaluated, Detail: detail} + } + + from, to := c.window(ev) + detail["window_from"] = from.UTC().Format(time.RFC3339) + detail["window_to"] = to.UTC().Format(time.RFC3339) + detail["post_window_seconds"] = int(DetectionPostWindow.Seconds()) + + // 3. Exact correlation first — time-independent and unambiguous. + // Checked BEFORE pipeline liveness: a stamped match is proof the + // pipeline is alive, and the liveness lookback could otherwise + // discard a match that arrived outside it. + if !correlationID.IsZero() { + detail["correlation_id"] = correlationID.String() + n, err := c.probe.CountByCorrelationID(ctx, tenantID, correlationID) + if err != nil { + detail["reason"] = "correlation lookup failed: " + err.Error() + return DetectionVerdict{Status: DetectionNotEvaluated, Detail: detail} + } + if n > 0 { + detail["match_mode"] = "correlation_id" + detail["matched_events"] = n + detail["confidence"] = "exact" + return DetectionVerdict{Status: DetectionObserved, Detail: detail} + } + } + + // 4. Is telemetry reaching us at all? This is the load-bearing + // check. Without it, a tenant that has never connected an EDR + // would see every validation reported as an undetected attack. + live, err := c.probe.PipelineLive(ctx, tenantID, c.now().Add(-telemetryLivenessLookback)) + if err != nil { + detail["reason"] = "telemetry pipeline check failed: " + err.Error() + return DetectionVerdict{Status: DetectionNotEvaluated, Detail: detail} + } + detail["telemetry_pipeline_live"] = live + if !live { + detail["liveness_lookback_hours"] = int(telemetryLivenessLookback.Hours()) + detail["reason"] = "no runtime telemetry received for this tenant in the lookback window — " + + "detection cannot be assessed. This is a missing telemetry integration, NOT a failed control." + return DetectionVerdict{Status: DetectionNoTelemetrySource, Detail: detail} + } + + // 5. Heuristic fallback: plausible event types on the target asset + // inside the window. + if !ev.Target.AssetID.IsZero() { + detail["target_asset_id"] = ev.Target.AssetID.String() + detail["heuristic_event_types"] = heuristicEventTypes + n, herr := c.probe.CountNearTarget(ctx, tenantID, ev.Target.AssetID, from, to, heuristicEventTypes) + if herr != nil { + detail["reason"] = "telemetry window query failed: " + herr.Error() + return DetectionVerdict{Status: DetectionNotEvaluated, Detail: detail} + } + if n > 0 { + detail["match_mode"] = "heuristic_asset_window" + detail["matched_events"] = n + detail["confidence"] = "heuristic" + detail["caveat"] = "matched by asset + time window, not by correlation id; " + + "unrelated activity on this asset inside the window can produce a false positive" + return DetectionVerdict{Status: DetectionObserved, Detail: detail} + } + detail["matched_events"] = 0 + detail["reason"] = "telemetry is flowing for this tenant but none correlated to this validation" + return DetectionVerdict{Status: DetectionNotObserved, Detail: detail} + } + + // 6. Pipeline is live but we have no asset to scope the window to, + // and nothing was stamped. Asserting a gap here would be guessing. + detail["reason"] = "validation target has no asset id; cannot scope a telemetry window" + return DetectionVerdict{Status: DetectionNotEvaluated, Detail: detail} +} + +// window returns the correlation bounds for the evidence. Falls back to +// the evaluation time when the executor did not report timestamps. +func (c *DetectionCorrelator) window(ev Evidence) (time.Time, time.Time) { + start := ev.StartedAt + end := ev.EndedAt + if start.IsZero() { + start = c.now() + } + if end.IsZero() || end.Before(start) { + end = start + } + return start.Add(-DetectionPreGrace), end.Add(DetectionPostWindow) +} diff --git a/internal/app/validation/detection_test.go b/internal/app/validation/detection_test.go new file mode 100644 index 00000000..a3c2a837 --- /dev/null +++ b/internal/app/validation/detection_test.go @@ -0,0 +1,358 @@ +package validation + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/openctemio/api/pkg/domain/shared" +) + +// fakeProbe is a scriptable TelemetryProbe that records what it was asked. +type fakeProbe struct { + live bool + liveErr error + byCorrID map[string]int + corrErr error + nearN int + nearErr error + + // captured arguments + gotSince time.Time + gotFrom time.Time + gotTo time.Time + gotTypes []string + nearCalls int + livenessCalls int +} + +func (f *fakeProbe) PipelineLive(_ context.Context, _ shared.ID, since time.Time) (bool, error) { + f.livenessCalls++ + f.gotSince = since + return f.live, f.liveErr +} + +func (f *fakeProbe) CountByCorrelationID(_ context.Context, _ shared.ID, correlationID shared.ID) (int, error) { + if f.corrErr != nil { + return 0, f.corrErr + } + return f.byCorrID[correlationID.String()], nil +} + +func (f *fakeProbe) CountNearTarget( + _ context.Context, _ shared.ID, _ shared.ID, from, to time.Time, eventTypes []string, +) (int, error) { + f.nearCalls++ + f.gotFrom, f.gotTo, f.gotTypes = from, to, eventTypes + return f.nearN, f.nearErr +} + +func baseEvidence() Evidence { + start := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + return Evidence{ + ExecutorKind: "safe-check", + Outcome: OutcomeDetected, // exposure still reachable + StartedAt: start, + EndedAt: start.Add(10 * time.Second), + Target: Target{AssetID: shared.NewID(), Type: "host", Address: "example.com:443"}, + } +} + +// --------------------------------------------------------------- +// The vocabulary invariant — the single most important property. +// --------------------------------------------------------------- + +// TestDetectionStatusDisjointFromOutcome is the guard on the central +// design decision: the detection verdict must never be expressible in +// the same words as the reachability outcome. `outcome='detected'` +// means the exposure is still reachable (bad); a detection verdict of +// "we saw it" is good. If a future change adds "detected" to +// DetectionStatus (or "observed" to Outcome), stored rows become +// ambiguous about which question they answer — and this test fails. +func TestDetectionStatusDisjointFromOutcome(t *testing.T) { + outcomes := []Outcome{ + OutcomeDetected, OutcomeNotDetected, OutcomeInconclusive, OutcomeError, OutcomeSkipped, + } + statuses := []DetectionStatus{ + DetectionObserved, DetectionNotObserved, DetectionNoTelemetrySource, + DetectionNotApplicable, DetectionNotEvaluated, + } + for _, o := range outcomes { + for _, d := range statuses { + if string(o) == string(d) { + t.Fatalf("vocabulary collision: Outcome %q and DetectionStatus %q share a value; "+ + "a stored row can no longer say which question it answers", o, d) + } + } + } +} + +// --------------------------------------------------------------- +// The case that will be wrong in the field. +// --------------------------------------------------------------- + +// TestNoTelemetryPipelineIsNotADetectionGap is the test the whole +// feature turns on. Today NO first-party producer writes +// runtime_telemetry_events, so this is the state every real tenant is +// in. Reporting it as "not_observed" would tell an operator their +// controls failed to catch an attack when the truth is that no +// telemetry source is connected at all. +func TestNoTelemetryPipelineIsNotADetectionGap(t *testing.T) { + probe := &fakeProbe{live: false} // nothing has ever arrived + c := NewDetectionCorrelator(probe) + + v := c.Evaluate(context.Background(), shared.NewID(), baseEvidence(), shared.ID{}) + + if v.Status != DetectionNoTelemetrySource { + t.Fatalf("status = %q, want %q", v.Status, DetectionNoTelemetrySource) + } + if v.Status.IsDetectionGap() { + t.Fatal("no_telemetry_source must NOT count as a detection gap — " + + "it is a missing integration, not a failed control") + } + if v.Status.IsConclusive() { + t.Fatal("no_telemetry_source must not be reported as a conclusive verdict") + } + // The verdict must carry the reason, or an operator sees a bare + // status and assumes the worst. + if v.Detail["reason"] == nil { + t.Fatal("verdict must explain why detection could not be assessed") + } + if live, ok := v.Detail["telemetry_pipeline_live"].(bool); !ok || live { + t.Fatalf("detail must record pipeline liveness=false, got %v", v.Detail["telemetry_pipeline_live"]) + } + // We must never have looked at the asset window: with no pipeline + // there is nothing to look at, and querying would imply we did. + if probe.nearCalls != 0 { + t.Fatalf("must not query the telemetry window when the pipeline is dead (calls=%d)", probe.nearCalls) + } +} + +// TestPipelineLiveButNothingCorrelated is the ONLY path allowed to +// assert a real control gap. +func TestPipelineLiveButNothingCorrelated(t *testing.T) { + probe := &fakeProbe{live: true, nearN: 0} + c := NewDetectionCorrelator(probe) + + v := c.Evaluate(context.Background(), shared.NewID(), baseEvidence(), shared.ID{}) + + if v.Status != DetectionNotObserved { + t.Fatalf("status = %q, want %q", v.Status, DetectionNotObserved) + } + if !v.Status.IsDetectionGap() { + t.Fatal("not_observed is the one status that should count as a detection gap") + } + if !v.Status.IsConclusive() { + t.Fatal("not_observed is based on a real look at telemetry; it is conclusive") + } +} + +// --------------------------------------------------------------- +// Matching +// --------------------------------------------------------------- + +func TestObservedViaCorrelationID(t *testing.T) { + corrID := shared.NewID() + // Pipeline reports NOT live, to prove the exact-match path does not + // depend on the liveness lookback: a stamped event is proof enough. + probe := &fakeProbe{live: false, byCorrID: map[string]int{corrID.String(): 3}} + c := NewDetectionCorrelator(probe) + + v := c.Evaluate(context.Background(), shared.NewID(), baseEvidence(), corrID) + + if v.Status != DetectionObserved { + t.Fatalf("status = %q, want %q", v.Status, DetectionObserved) + } + if v.Detail["match_mode"] != "correlation_id" { + t.Fatalf("match_mode = %v, want correlation_id", v.Detail["match_mode"]) + } + if v.Detail["confidence"] != "exact" { + t.Fatalf("confidence = %v, want exact", v.Detail["confidence"]) + } + if v.Detail["matched_events"] != 3 { + t.Fatalf("matched_events = %v, want 3", v.Detail["matched_events"]) + } +} + +func TestObservedViaHeuristicCarriesItsCaveat(t *testing.T) { + probe := &fakeProbe{live: true, nearN: 1} + c := NewDetectionCorrelator(probe) + + v := c.Evaluate(context.Background(), shared.NewID(), baseEvidence(), shared.ID{}) + + if v.Status != DetectionObserved { + t.Fatalf("status = %q, want %q", v.Status, DetectionObserved) + } + if v.Detail["match_mode"] != "heuristic_asset_window" { + t.Fatalf("match_mode = %v, want heuristic_asset_window", v.Detail["match_mode"]) + } + // A heuristic match must never be presented as equivalent to an + // exact one — unrelated activity on a busy asset can produce it. + if v.Detail["confidence"] != "heuristic" { + t.Fatalf("confidence = %v, want heuristic", v.Detail["confidence"]) + } + if v.Detail["caveat"] == nil { + t.Fatal("a heuristic match must record its false-positive caveat") + } +} + +// TestHeuristicWindowBounds pins the correlation window actually used. +func TestHeuristicWindowBounds(t *testing.T) { + probe := &fakeProbe{live: true, nearN: 0} + c := NewDetectionCorrelator(probe) + ev := baseEvidence() + + c.Evaluate(context.Background(), shared.NewID(), ev, shared.ID{}) + + wantFrom := ev.StartedAt.Add(-DetectionPreGrace) + wantTo := ev.EndedAt.Add(DetectionPostWindow) + if !probe.gotFrom.Equal(wantFrom) { + t.Fatalf("window from = %v, want %v", probe.gotFrom, wantFrom) + } + if !probe.gotTo.Equal(wantTo) { + t.Fatalf("window to = %v, want %v", probe.gotTo, wantTo) + } + // The heuristic must not sweep in host noise that a remote network + // probe could not have caused. + for _, et := range probe.gotTypes { + if et != "network_connect" && et != "auth_attempt" { + t.Fatalf("heuristic matched implausible event type %q", et) + } + } + if len(probe.gotTypes) == 0 { + t.Fatal("heuristic must restrict event types, not match everything") + } +} + +// --------------------------------------------------------------- +// "We did not look" paths — none may assert a gap. +// --------------------------------------------------------------- + +func TestNonExecutedValidationIsNotApplicable(t *testing.T) { + for _, oc := range []Outcome{OutcomeError, OutcomeSkipped} { + probe := &fakeProbe{live: true, nearN: 0} + c := NewDetectionCorrelator(probe) + ev := baseEvidence() + ev.Outcome = oc + + v := c.Evaluate(context.Background(), shared.NewID(), ev, shared.ID{}) + + if v.Status != DetectionNotApplicable { + t.Fatalf("outcome %q: status = %q, want %q", oc, v.Status, DetectionNotApplicable) + } + if v.Status.IsDetectionGap() { + t.Fatalf("outcome %q: nothing executed, so a control cannot have missed it", oc) + } + if probe.livenessCalls != 0 { + t.Fatalf("outcome %q: must short-circuit before touching telemetry", oc) + } + } +} + +func TestNilProbeReportsNotEvaluated(t *testing.T) { + c := NewDetectionCorrelator(nil) + v := c.Evaluate(context.Background(), shared.NewID(), baseEvidence(), shared.ID{}) + if v.Status != DetectionNotEvaluated { + t.Fatalf("status = %q, want %q", v.Status, DetectionNotEvaluated) + } + if v.Status.IsDetectionGap() { + t.Fatal("an unwired correlator must never report a detection gap") + } +} + +// TestProbeErrorsNeverBecomeDetectionGaps: a DB failure must degrade to +// "we don't know", never to "your controls missed it". +func TestProbeErrorsNeverBecomeDetectionGaps(t *testing.T) { + boom := errors.New("connection reset") + cases := map[string]*fakeProbe{ + "liveness query failed": {liveErr: boom}, + "correlation query failed": {corrErr: boom, live: true}, + "window query failed": {live: true, nearErr: boom}, + } + for name, probe := range cases { + c := NewDetectionCorrelator(probe) + corrID := shared.ID{} + if name == "correlation query failed" { + corrID = shared.NewID() + } + v := c.Evaluate(context.Background(), shared.NewID(), baseEvidence(), corrID) + if v.Status != DetectionNotEvaluated { + t.Fatalf("%s: status = %q, want %q", name, v.Status, DetectionNotEvaluated) + } + if v.Status.IsDetectionGap() { + t.Fatalf("%s: a query failure must not indict a control", name) + } + } +} + +// TestNoAssetIDCannotAssertAGap: with nothing stamped and no asset to +// scope a window to, we have not looked anywhere. +func TestNoAssetIDCannotAssertAGap(t *testing.T) { + probe := &fakeProbe{live: true} + c := NewDetectionCorrelator(probe) + ev := baseEvidence() + ev.Target.AssetID = shared.ID{} + + v := c.Evaluate(context.Background(), shared.NewID(), ev, shared.ID{}) + + if v.Status != DetectionNotEvaluated { + t.Fatalf("status = %q, want %q", v.Status, DetectionNotEvaluated) + } + if v.Status.IsDetectionGap() { + t.Fatal("without a scoped window we have not looked; cannot claim a gap") + } +} + +// --------------------------------------------------------------- +// Store integration — the verdict must actually be persisted. +// --------------------------------------------------------------- + +func TestEvidenceStorePersistsDetectionVerdict(t *testing.T) { + repo := &memEvidenceRepo{} + s := NewEvidenceStore(repo) + s.SetDetectionCorrelator(NewDetectionCorrelator(&fakeProbe{live: false})) + + stored, err := s.Record(context.Background(), shared.NewID(), shared.NewID(), nil, baseEvidence()) + if err != nil { + t.Fatalf("Record: %v", err) + } + if stored.DetectionStatus != DetectionNoTelemetrySource { + t.Fatalf("returned status = %q, want %q", stored.DetectionStatus, DetectionNoTelemetrySource) + } + if len(repo.rows) != 1 || repo.rows[0].DetectionStatus != DetectionNoTelemetrySource { + t.Fatalf("persisted status = %q, want %q — the verdict must reach the repo, not just the return value", + repo.rows[0].DetectionStatus, DetectionNoTelemetrySource) + } + if repo.rows[0].DetectionDetail["reason"] == nil { + t.Fatal("persisted row must carry the reason for the verdict") + } +} + +// A store with no correlator wired must record not_evaluated — never an +// empty status (which a reader could coerce to "no detection") and +// never a gap. +func TestEvidenceStoreWithoutCorrelatorRecordsNotEvaluated(t *testing.T) { + repo := &memEvidenceRepo{} + s := NewEvidenceStore(repo) + + stored, err := s.Record(context.Background(), shared.NewID(), shared.NewID(), nil, baseEvidence()) + if err != nil { + t.Fatalf("Record: %v", err) + } + if stored.DetectionStatus != DetectionNotEvaluated { + t.Fatalf("status = %q, want %q", stored.DetectionStatus, DetectionNotEvaluated) + } + if stored.DetectionStatus.IsDetectionGap() { + t.Fatal("an unwired store must not report a detection gap") + } +} + +func TestValidDetectionStatus(t *testing.T) { + if ValidDetectionStatus("detected") { + t.Fatal(`"detected" is an Outcome, not a DetectionStatus — it must not validate`) + } + if !ValidDetectionStatus(DetectionNoTelemetrySource) { + t.Fatal("no_telemetry_source must be a valid status") + } +} diff --git a/internal/app/validation/evidence_store.go b/internal/app/validation/evidence_store.go index 3f6e7ad1..f952ff68 100644 --- a/internal/app/validation/evidence_store.go +++ b/internal/app/validation/evidence_store.go @@ -32,6 +32,15 @@ type StoredEvidence struct { SimulationRunID *shared.ID // optional; populated when evidence is part of a scheduled simulation Evidence Evidence CreatedAt time.Time + + // DetectionStatus answers "did any control observe this?" — a + // SEPARATE question from Evidence.Outcome ("is the exposure still + // reachable?"). Defaults to DetectionNotEvaluated so a record never + // implies a control failed just because correlation did not run. + DetectionStatus DetectionStatus + // DetectionDetail records how the verdict was reached (match mode, + // window, pipeline liveness) so an operator can audit it. + DetectionDetail map[string]any } // EvidenceRepository persists StoredEvidence. Implemented by a @@ -44,12 +53,15 @@ type EvidenceRepository interface { // EvidenceStore is the app-layer facade. Calls redact → persist → // returns the stored record so callers can surface it. type EvidenceStore struct { - repo EvidenceRepository - redactor *Redactor - now func() time.Time + repo EvidenceRepository + redactor *Redactor + now func() time.Time + detections *DetectionCorrelator // optional; nil → DetectionNotEvaluated } -// NewEvidenceStore wires defaults. +// NewEvidenceStore wires defaults. Detection correlation is off until +// SetDetectionCorrelator is called — until then every record is stored +// as DetectionNotEvaluated, never as a detection gap. func NewEvidenceStore(repo EvidenceRepository) *EvidenceStore { return &EvidenceStore{ repo: repo, @@ -58,6 +70,12 @@ func NewEvidenceStore(repo EvidenceRepository) *EvidenceStore { } } +// SetDetectionCorrelator enables "did our controls react?" evaluation. +// Called from services bootstrap after both the store and the telemetry +// probe exist — mirrors RuntimeTelemetryHandler.SetCorrelator and avoids +// a wiring-order cycle. +func (s *EvidenceStore) SetDetectionCorrelator(c *DetectionCorrelator) { s.detections = c } + // Record persists the evidence after redaction. Returns the stored // envelope with ID populated. Errors from the repo are propagated. func (s *EvidenceStore) Record( @@ -70,6 +88,19 @@ func (s *EvidenceStore) Record( return StoredEvidence{}, fmt.Errorf("%w: tenant and finding ids are required", shared.ErrValidation) } redacted := s.redactor.Redact(ev) + + // Detection verdict — the "did our controls react?" half of Stage-4. + // Correlation failures degrade to DetectionNotEvaluated inside + // Evaluate; they never block persisting the evidence, and they never + // produce a detection gap. + verdict := DetectionVerdict{ + Status: DetectionNotEvaluated, + Detail: map[string]any{"reason": "detection correlation not configured"}, + } + if s.detections != nil { + verdict = s.detections.Evaluate(ctx, tenantID, redacted, ev.CorrelationID) + } + stored := StoredEvidence{ ID: shared.NewID(), TenantID: tenantID, @@ -77,6 +108,8 @@ func (s *EvidenceStore) Record( SimulationRunID: simulationRunID, Evidence: redacted, CreatedAt: s.now(), + DetectionStatus: verdict.Status, + DetectionDetail: verdict.Detail, } if err := s.repo.Create(ctx, stored); err != nil { return StoredEvidence{}, fmt.Errorf("persist evidence: %w", err) diff --git a/internal/app/validation/executor.go b/internal/app/validation/executor.go index 3cec8d13..829fcac6 100644 --- a/internal/app/validation/executor.go +++ b/internal/app/validation/executor.go @@ -57,6 +57,13 @@ type Evidence struct { Summary string Artifacts []string // attachment IDs (screenshots, PCAPs) RawMeta map[string]any + + // CorrelationID ties this execution to runtime telemetry that a + // producer stamped with the same id, giving an exact, + // time-independent detection match. Zero when nothing stamped + // anything — the correlator then falls back to an asset+window + // heuristic. See detection.go. + CorrelationID shared.ID } // Executor is a back-compat accessor for legacy handler code that diff --git a/internal/infra/controller/audit_chain_system_test.go b/internal/infra/controller/audit_chain_system_test.go new file mode 100644 index 00000000..f8ab4a71 --- /dev/null +++ b/internal/infra/controller/audit_chain_system_test.go @@ -0,0 +1,92 @@ +package controller + +import ( + "context" + "testing" + + auditdom "github.com/openctemio/api/pkg/domain/audit" + "github.com/openctemio/api/pkg/domain/shared" +) + +// The audit hash chain is keyed by tenant, and authentication events have no +// tenant — at login a user may belong to several tenants and has not chosen +// one. They were therefore skipped entirely. On the live database that left +// 925 of 1075 audit rows (86%) with no tamper evidence, including every +// auth.login, auth.register and auth.failed. +// +// They now extend a dedicated system chain. But writing hashes nobody checks is +// not tamper evidence, and ListActiveTenantIDs can never return the system +// chain because it is not a tenant. So the controller has to add it, and that +// is what these tests hold in place. + +func TestAuditChainVerify_WalksTheSystemChain(t *testing.T) { + ids := mkTenantIDs(2) + verifier := &chainVerifierMock{} + + c := newTestController(t, verifier, &tenantListerMock{ids: ids}) + if _, err := c.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + var sawSystem bool + for _, got := range verifier.calls { + if got == auditdom.SystemChainTenantID.String() { + sawSystem = true + break + } + } + if !sawSystem { + t.Fatalf("the system chain was never verified. Every authentication event "+ + "lives on it, so its hashes are stored but unchecked — which is not "+ + "tamper evidence. Chains walked: %v", verifier.calls) + } +} + +// A partial run — the context deadline expires part-way — must not be able to +// skip the chain carrying the authentication records. Walking it first is the +// property; asserting "it is in the list somewhere" would not catch a change +// that appends it at the end. +func TestAuditChainVerify_SystemChainIsWalkedFirst(t *testing.T) { + verifier := &chainVerifierMock{} + + c := newTestController(t, verifier, &tenantListerMock{ids: mkTenantIDs(3)}) + if _, err := c.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + if len(verifier.calls) == 0 { + t.Fatal("no chains were verified at all") + } + if verifier.calls[0] != auditdom.SystemChainTenantID.String() { + t.Fatalf("first chain walked = %v, want the system chain. A run cut short "+ + "by its context would skip whatever is last, and the authentication "+ + "trail is the part an intruder has the most reason to edit", + verifier.calls[0]) + } +} + +// The sentinel must never collide with a generated ID. Its version nibble is +// 'f'; uuid.NewV7 and uuid.New can only ever emit 7 or 4 there. This asserts +// the property rather than the constant, so it keeps holding if the value is +// ever changed. +func TestSystemChainTenantID_CannotCollideWithAGeneratedID(t *testing.T) { + sentinel := auditdom.SystemChainTenantID.String() + + if sentinel == (shared.ID{}).String() { + t.Fatal("the sentinel is the zero value of shared.ID, which call sites " + + "already use IsZero() to mean \"unset\"") + } + + // UUID version nibble: character 15 of 8-4-4-4-12. + if v := sentinel[14]; v == '4' || v == '7' { + t.Fatalf("sentinel version nibble is %q — a generated UUID could collide "+ + "with it, and then a real tenant's chain would merge with the system "+ + "chain", v) + } + + for i := 0; i < 2000; i++ { + if shared.NewID().String() == sentinel { + t.Fatal("NewID produced the sentinel") + } + } +} diff --git a/internal/infra/controller/audit_chain_verify.go b/internal/infra/controller/audit_chain_verify.go index 98ff7d75..76a83e26 100644 --- a/internal/infra/controller/audit_chain_verify.go +++ b/internal/infra/controller/audit_chain_verify.go @@ -7,6 +7,7 @@ import ( "github.com/openctemio/api/internal/app/audit" "github.com/openctemio/api/internal/metrics" + auditdom "github.com/openctemio/api/pkg/domain/audit" "github.com/openctemio/api/pkg/domain/shared" tenantdom "github.com/openctemio/api/pkg/domain/tenant" "github.com/openctemio/api/pkg/logger" @@ -131,6 +132,14 @@ func (c *AuditChainVerifyController) Reconcile(ctx context.Context) (int, error) return 0, fmt.Errorf("list active tenants: %w", err) } + // The system chain is not a tenant, so ListActiveTenantIDs will never + // return it — and a chain nobody walks is not tamper-evident, it is just + // stored hashes. It carries every authentication event, which is the part + // of the trail an intruder has the most reason to edit, so it is walked + // FIRST rather than appended at the end where a partial run (ctx deadline) + // could skip it. + tenantIDs = append([]shared.ID{auditdom.SystemChainTenantID}, tenantIDs...) + processed := 0 totalBreaks := 0 newBreaks := 0 diff --git a/internal/infra/controller/audit_chain_verify_test.go b/internal/infra/controller/audit_chain_verify_test.go index 1cd160a1..34b97a49 100644 --- a/internal/infra/controller/audit_chain_verify_test.go +++ b/internal/infra/controller/audit_chain_verify_test.go @@ -101,11 +101,13 @@ func TestAuditChainVerify_CleanChain_NoErrors(t *testing.T) { if err != nil { t.Fatalf("Reconcile: %v", err) } - if processed != 3 { - t.Errorf("processed: want 3, got %d", processed) + // 3 tenants + the system chain. The system chain is not a tenant, so + // ListActiveTenantIDs never returns it; the controller adds it. + if processed != 4 { + t.Errorf("processed: want 4 (3 tenants + system chain), got %d", processed) } - if len(verifier.calls) != 3 { - t.Errorf("verifier called %d times, want 3", len(verifier.calls)) + if len(verifier.calls) != 4 { + t.Errorf("verifier called %d times, want 4", len(verifier.calls)) } } @@ -137,8 +139,8 @@ func TestAuditChainVerify_BreaksStillCountAsProcessed(t *testing.T) { // VerifyChain. The break is emitted via the logger (tested via // absence of error below — visual SIEM alerting is out of scope // for unit tests). - if processed != 2 { - t.Errorf("processed: want 2 (both tenants visited), got %d", processed) + if processed != 3 { + t.Errorf("processed: want 3 (both tenants + system chain), got %d", processed) } } @@ -160,12 +162,12 @@ func TestAuditChainVerify_PerTenantErrorSkipsButContinues(t *testing.T) { t.Fatalf("per-tenant error should not fail the run, got %v", err) } // processed counts only successful verifications; tenant[1] failed. - if processed != 2 { - t.Errorf("processed: want 2 (one failure skipped), got %d", processed) + if processed != 3 { + t.Errorf("processed: want 3 (4 chains, one failure skipped), got %d", processed) } // All three were attempted though. - if len(verifier.calls) != 3 { - t.Errorf("verifier should have been called for all 3 tenants even after one errored; got %d", len(verifier.calls)) + if len(verifier.calls) != 4 { + t.Errorf("verifier should have been called for all 3 tenants + the system chain even after one errored; got %d", len(verifier.calls)) } } diff --git a/internal/infra/controller/job_recovery.go b/internal/infra/controller/job_recovery.go index 751f9d5c..27757003 100644 --- a/internal/infra/controller/job_recovery.go +++ b/internal/infra/controller/job_recovery.go @@ -155,6 +155,10 @@ func (c *JobRecoveryController) Reconcile(ctx context.Context) (int, error) { // ever reaped them. Every platform job that timed out in the queue took its // pipeline run down silently, and the run hung until ScanTimeoutController // reported a generic timeout instead of "expired in queue". + // + // Both raw-UPDATE reapers have since been deleted from the repository + // entirely, so this cannot be reintroduced by accident: expiry has exactly + // one implementation and it notifies the run. // Step 3: Fail commands that have exceeded max retry attempts failedExhausted, err := c.commandRepo.FailExhaustedCommands(ctx, c.config.MaxRetries) diff --git a/internal/infra/controller/job_recovery_test.go b/internal/infra/controller/job_recovery_test.go index 5f0b69fa..7ce76df2 100644 --- a/internal/infra/controller/job_recovery_test.go +++ b/internal/infra/controller/job_recovery_test.go @@ -12,7 +12,6 @@ import ( // recordingCommandRepo records which recovery/expiry methods Reconcile calls. type recordingCommandRepo struct { - calledExpireOldCommands bool calledFindQueueExpiredPlatformJobs bool recoverStuckJobs int64 @@ -37,11 +36,6 @@ func (r *recordingCommandRepo) FailExhaustedCommands(_ context.Context, _ int) ( return r.failExhaustedCommands, nil } -func (r *recordingCommandRepo) ExpireOldCommands(_ context.Context) (int64, error) { - r.calledExpireOldCommands = true - return 7, nil -} - // --- remaining command.Repository surface: unused by JobRecoveryController --- func (r *recordingCommandRepo) Create(context.Context, *command.Command) error { return nil } @@ -116,39 +110,17 @@ func (r *recordingCommandRepo) CancelByPipelineRunID(context.Context, shared.ID, var _ command.Repository = (*recordingCommandRepo)(nil) -// TestJobRecoveryController_DoesNotExpireRegularCommands pins the removal of a -// duplicate expiry path. -// -// app/command.ExpirationChecker is the owner of regular-command expiry: it ticks -// every 60s, selects status IN ('pending','acknowledged') AND expires_at < now -// via FindExpired, marks each row expired, and calls -// pipeline.OnStepFailed(..., "COMMAND_EXPIRED") so the owning pipeline run is -// told its step died. -// -// This controller ticked on the same 60s interval and additionally ran +// NOTE: this file used to also assert that Reconcile never calls // CommandRepository.ExpireOldCommands — a raw `UPDATE commands SET -// status='expired' WHERE status='pending' AND expires_at < NOW()`, a strict -// subset of the same rows and with no pipeline notification. Whenever it won the -// race, FindExpired no longer matched the row and the run was never notified. +// status='expired' WHERE status='pending' AND expires_at < NOW()` that ran on +// the same 60s tick over a strict subset of the rows FindExpired matches, with +// no pipeline notification. Whenever it won that race, FindExpired no longer +// matched the row and the owning run was never told its step died. // -// Reconcile must therefore never touch ExpireOldCommands. -func TestJobRecoveryController_DoesNotExpireRegularCommands(t *testing.T) { - repo := &recordingCommandRepo{} - c := NewJobRecoveryController(repo, &JobRecoveryControllerConfig{ - Logger: logger.NewNop(), - }) - - if _, err := c.Reconcile(context.Background()); err != nil { - t.Fatalf("Reconcile: %v", err) - } - - if repo.calledExpireOldCommands { - t.Fatal("JobRecoveryController.Reconcile called ExpireOldCommands: " + - "this races app/command.ExpirationChecker on the same 60s tick and, when it " + - "wins, expires the command without ever calling pipeline OnStepFailed - the " + - "pipeline run is left hanging with no COMMAND_EXPIRED step failure") - } -} +// ExpireOldCommands has since been deleted from command.Repository, its postgres +// implementation and the command service, so the guarantee is now enforced by +// the compiler rather than by a test. app/command.ExpirationChecker is the only +// expiry path and it calls pipeline.OnStepFailed(..., "COMMAND_EXPIRED"). // TestJobRecoveryController_DoesNotExpirePlatformJobs pins the same removal one // step over, for the platform-job queue. @@ -182,8 +154,9 @@ func TestJobRecoveryController_DoesNotExpirePlatformJobs(t *testing.T) { } // TestJobRecoveryController_ReportsOnlyItsOwnWork guards the item count after the -// removals: neither the ExpireOldCommands result (7 above) nor any platform-job -// expiry may be folded in. +// removals: no expiry result — neither the tenant-command expiry that +// ExpireOldCommands used to return nor any platform-job expiry — may be folded +// into this controller's count. func TestJobRecoveryController_ReportsOnlyItsOwnWork(t *testing.T) { repo := &recordingCommandRepo{ recoverStuckJobs: 1, diff --git a/internal/infra/http/handler/agent_handler.go b/internal/infra/http/handler/agent_handler.go index db232b05..084f457c 100644 --- a/internal/infra/http/handler/agent_handler.go +++ b/internal/infra/http/handler/agent_handler.go @@ -461,7 +461,7 @@ type AgentDisableRequest struct { // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /agents/{id}/disable [post] +// @Router /agents/{id}/deactivate [post] func (h *AgentHandler) Disable(w http.ResponseWriter, r *http.Request) { agentID := chi.URLParam(r, "id") tenantID := middleware.GetTenantID(r.Context()) @@ -642,7 +642,7 @@ type AvailableCapabilitiesResponse struct { // @Success 200 {object} AvailableCapabilitiesResponse // @Failure 401 {object} apierror.Error "Unauthorized" // @Failure 500 {object} apierror.Error "Internal server error" -// @Router /api/v1/agents/available-capabilities [get] +// @Router /agents/available-capabilities [get] func (h *AgentHandler) GetAvailableCapabilities(w http.ResponseWriter, r *http.Request) { tenantIDStr := middleware.GetTenantID(r.Context()) diff --git a/internal/infra/http/handler/asset_group_handler.go b/internal/infra/http/handler/asset_group_handler.go index 2b8b8931..fed4e23a 100644 --- a/internal/infra/http/handler/asset_group_handler.go +++ b/internal/infra/http/handler/asset_group_handler.go @@ -365,7 +365,7 @@ func (h *AssetGroupHandler) Create(w http.ResponseWriter, r *http.Request) { // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /asset-groups/{id} [patch] +// @Router /asset-groups/{id} [put] func (h *AssetGroupHandler) Update(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := shared.IDFromString(idStr) @@ -660,7 +660,7 @@ func (h *AssetGroupHandler) AddAssets(w http.ResponseWriter, r *http.Request) { // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /asset-groups/{id}/assets/remove [post] +// @Router /asset-groups/{id}/assets [delete] func (h *AssetGroupHandler) RemoveAssets(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := shared.IDFromString(idStr) @@ -750,7 +750,7 @@ func (h *AssetGroupHandler) BulkUpdate(w http.ResponseWriter, r *http.Request) { // @Failure 400 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /asset-groups/bulk/delete [post] +// @Router /asset-groups/bulk [delete] func (h *AssetGroupHandler) BulkDelete(w http.ResponseWriter, r *http.Request) { var req BulkDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/internal/infra/http/handler/asset_handler.go b/internal/infra/http/handler/asset_handler.go index 3e1d794c..590a4ec3 100644 --- a/internal/infra/http/handler/asset_handler.go +++ b/internal/infra/http/handler/asset_handler.go @@ -1759,7 +1759,7 @@ func (h *AssetHandler) Sync(w http.ResponseWriter, r *http.Request) { // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /assets/bulk-sync [post] +// @Router /assets/bulk/sync [post] func (h *AssetHandler) BulkSync(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) ctx := r.Context() diff --git a/internal/infra/http/handler/auth_handler.go b/internal/infra/http/handler/auth_handler.go index 440629f6..994446f1 100644 --- a/internal/infra/http/handler/auth_handler.go +++ b/internal/infra/http/handler/auth_handler.go @@ -35,12 +35,13 @@ type KeycloakInfoResponse struct { } // Info returns Keycloak configuration info. -// @Summary Get Keycloak info -// @Description Returns Keycloak server configuration URLs and realm info -// @Tags Authentication -// @Produce json -// @Success 200 {object} KeycloakInfoResponse -// @Router /auth/keycloak/info [get] +// +// Served at GET /auth/info, but only in the OIDC branch of registerAuthRoutes +// (routes/auth.go) — LocalAuthHandler.Info serves the same path in the local +// branch and the two are mutually exclusive at runtime. Since the spec is +// generated, both handlers cannot annotate the one path: LocalAuthHandler +// carries the @Router and this one deliberately does not. The annotation this +// replaces claimed /auth/keycloak/info, which no router has ever served. func (h *AuthHandler) Info(w http.ResponseWriter, r *http.Request) { baseURL := h.keycloakCfg.BaseURL realm := h.keycloakCfg.Realm @@ -62,12 +63,11 @@ func (h *AuthHandler) Info(w http.ResponseWriter, r *http.Request) { } // GenerateToken is deprecated - tokens are now issued by Keycloak. -// @Summary Generate token (deprecated) -// @Description Deprecated endpoint - returns redirect instruction to Keycloak OAuth flow -// @Tags Authentication -// @Produce json -// @Success 200 {object} map[string]string -// @Router /auth/keycloak/token [post] +// +// Served at POST /auth/token in the OIDC branch only; LocalAuthHandler.Login +// serves the same path in the local branch and carries the @Router. See Info +// above. The annotation this replaces claimed /auth/keycloak/token, which no +// router has ever served. func (h *AuthHandler) GenerateToken(w http.ResponseWriter, r *http.Request) { baseURL := h.keycloakCfg.BaseURL realm := h.keycloakCfg.Realm diff --git a/internal/infra/http/handler/command_handler.go b/internal/infra/http/handler/command_handler.go index a298c811..40fb9183 100644 --- a/internal/infra/http/handler/command_handler.go +++ b/internal/infra/http/handler/command_handler.go @@ -593,6 +593,12 @@ func (h *CommandHandler) triggerValidationEvidence(cmd *commanddom.Command) { ExecutorKind: payload.ExecutorKind, Technique: validation.TechniqueID(payload.Technique), Target: validation.Target{ + // AssetID was previously dropped here. The detection + // correlator needs it to scope a telemetry window to the + // asset that was probed, so carry it through. Parse + // failures leave it zero, which the correlator treats as + // "cannot scope" (not_evaluated) rather than guessing. + AssetID: parseOptionalID(payload.Target.AssetID), Type: payload.Target.Type, Address: payload.Target.Address, }, @@ -601,6 +607,10 @@ func (h *CommandHandler) triggerValidationEvidence(cmd *commanddom.Command) { Outcome: validation.Outcome(verdict.Outcome), Summary: verdict.Summary, RawMeta: verdict.Evidence, + // The command id IS the correlation key — it is the identifier a + // telemetry producer can be told to stamp on events it emits + // while reacting to this job. See validation/detection.go. + CorrelationID: cmd.ID, } tenantID := cmd.TenantID @@ -617,6 +627,21 @@ func (h *CommandHandler) triggerValidationEvidence(cmd *commanddom.Command) { }() } +// parseOptionalID parses an id that is legitimately allowed to be absent +// or malformed, returning the zero ID in those cases. Used for payload +// fields where a missing id degrades a feature rather than failing the +// request. +func parseOptionalID(s string) shared.ID { + if s == "" { + return shared.ID{} + } + id, err := shared.IDFromString(s) + if err != nil { + return shared.ID{} + } + return id +} + // triggerPipelineProgression triggers pipeline progression when a command completes. // It extracts pipeline info from the command payload and calls OnStepCompleted. func (h *CommandHandler) triggerPipelineProgression(ctx context.Context, cmd *commanddom.Command) { diff --git a/internal/infra/http/handler/credential_import_handler.go b/internal/infra/http/handler/credential_import_handler.go index 9a8e2967..c768eef0 100644 --- a/internal/infra/http/handler/credential_import_handler.go +++ b/internal/infra/http/handler/credential_import_handler.go @@ -118,7 +118,7 @@ type ImportMetadataRequest struct { // @Success 201 {object} credential.ImportResult // @Failure 400 {object} apierror.Error // @Failure 401 {object} apierror.Error -// @Router /api/v1/credentials/import [post] +// @Router /credentials/import [post] func (h *CredentialImportHandler) Import(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) @@ -165,7 +165,7 @@ func (h *CredentialImportHandler) Import(w http.ResponseWriter, r *http.Request) // @Success 201 {object} credential.ImportResult // @Failure 400 {object} apierror.Error // @Failure 401 {object} apierror.Error -// @Router /api/v1/credentials/import/csv [post] +// @Router /credentials/import/csv [post] func (h *CredentialImportHandler) ImportCSV(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) @@ -226,7 +226,7 @@ func (h *CredentialImportHandler) ImportCSV(w http.ResponseWriter, r *http.Reque // @Param sort query string false "Sort field (prefix - for desc)" // @Success 200 {object} app.CredentialListResult // @Failure 401 {object} apierror.Error -// @Router /api/v1/credentials [get] +// @Router /credentials [get] func (h *CredentialImportHandler) List(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) @@ -281,7 +281,7 @@ func (h *CredentialImportHandler) List(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} app.CredentialItem // @Failure 404 {object} apierror.Error // @Failure 401 {object} apierror.Error -// @Router /api/v1/credentials/{id} [get] +// @Router /credentials/{id} [get] func (h *CredentialImportHandler) GetByID(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) id := r.PathValue("id") @@ -309,7 +309,7 @@ func (h *CredentialImportHandler) GetByID(w http.ResponseWriter, r *http.Request // @Produce json // @Success 200 {object} map[string]any // @Failure 401 {object} apierror.Error -// @Router /api/v1/credentials/stats [get] +// @Router /credentials/stats [get] func (h *CredentialImportHandler) GetStats(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) @@ -336,7 +336,7 @@ func (h *CredentialImportHandler) GetStats(w http.ResponseWriter, r *http.Reques // @Param search query string false "Search in identifier" // @Success 200 {object} app.IdentityListResult // @Failure 401 {object} apierror.Error -// @Router /api/v1/credentials/identities [get] +// @Router /credentials/identities [get] func (h *CredentialImportHandler) ListByIdentity(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) @@ -384,7 +384,7 @@ func (h *CredentialImportHandler) ListByIdentity(w http.ResponseWriter, r *http. // @Success 200 {array} app.CredentialItem // @Failure 404 {object} apierror.Error // @Failure 401 {object} apierror.Error -// @Router /api/v1/credentials/{id}/related [get] +// @Router /credentials/{id}/related [get] func (h *CredentialImportHandler) GetRelatedCredentials(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) id := r.PathValue("id") @@ -415,7 +415,7 @@ func (h *CredentialImportHandler) GetRelatedCredentials(w http.ResponseWriter, r // @Param page_size query int false "Page size" default(20) // @Success 200 {object} app.CredentialListResult // @Failure 401 {object} apierror.Error -// @Router /api/v1/credentials/identities/{identity}/exposures [get] +// @Router /credentials/identities/{identity}/exposures [get] func (h *CredentialImportHandler) GetExposuresForIdentity(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) identity := r.PathValue("identity") @@ -456,7 +456,7 @@ func (h *CredentialImportHandler) GetExposuresForIdentity(w http.ResponseWriter, // @Tags Credentials // @Produce text/csv // @Success 200 {file} file "CSV template" -// @Router /api/v1/credentials/import/template [get] +// @Router /credentials/import/template [get] func (h *CredentialImportHandler) GetTemplate(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/csv") w.Header().Set("Content-Disposition", "attachment; filename=credential_import_template.csv") @@ -569,7 +569,7 @@ func (h *CredentialImportHandler) GetTemplate(w http.ResponseWriter, r *http.Req // @Tags Credentials // @Produce json // @Success 200 {object} map[string]any -// @Router /api/v1/credentials/enums [get] +// @Router /credentials/enums [get] func (h *CredentialImportHandler) GetEnums(w http.ResponseWriter, _ *http.Request) { credentialTypes := make([]string, 0, len(credential.AllCredentialTypes())) for _, t := range credential.AllCredentialTypes() { @@ -773,7 +773,7 @@ type CredentialStateChangeRequest struct { // @Success 200 {object} app.CredentialItem // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/credentials/{id}/resolve [post] +// @Router /credentials/{id}/resolve [post] func (h *CredentialImportHandler) Resolve(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) userID := middleware.GetUserID(r.Context()) @@ -813,7 +813,7 @@ func (h *CredentialImportHandler) Resolve(w http.ResponseWriter, r *http.Request // @Success 200 {object} app.CredentialItem // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/credentials/{id}/accept [post] +// @Router /credentials/{id}/accept [post] func (h *CredentialImportHandler) Accept(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) userID := middleware.GetUserID(r.Context()) @@ -853,7 +853,7 @@ func (h *CredentialImportHandler) Accept(w http.ResponseWriter, r *http.Request) // @Success 200 {object} app.CredentialItem // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/credentials/{id}/false-positive [post] +// @Router /credentials/{id}/false-positive [post] func (h *CredentialImportHandler) MarkFalsePositive(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) userID := middleware.GetUserID(r.Context()) @@ -891,7 +891,7 @@ func (h *CredentialImportHandler) MarkFalsePositive(w http.ResponseWriter, r *ht // @Success 200 {object} app.CredentialItem // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/credentials/{id}/reactivate [post] +// @Router /credentials/{id}/reactivate [post] func (h *CredentialImportHandler) Reactivate(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) id := r.PathValue("id") diff --git a/internal/infra/http/handler/finding_source_handler.go b/internal/infra/http/handler/finding_source_handler.go index 70ad69bc..d47309dc 100644 --- a/internal/infra/http/handler/finding_source_handler.go +++ b/internal/infra/http/handler/finding_source_handler.go @@ -175,7 +175,7 @@ func (h *FindingSourceHandler) handleServiceError(w http.ResponseWriter, err err // @Failure 400 {object} apierror.Error // @Failure 401 {object} apierror.Error // @Failure 500 {object} apierror.Error -// @Router /config/finding-sources/categories [get] +// @Router /finding-sources/categories [get] func (h *FindingSourceHandler) ListCategories(w http.ResponseWriter, r *http.Request) { //nolint:dupl // Similar to AssetTypeHandler.ListCategories but different types query := r.URL.Query() activeOnly := query.Get("active_only") == queryParamTrue @@ -270,7 +270,7 @@ func (h *FindingSourceHandler) ListCategories(w http.ResponseWriter, r *http.Req // @Failure 401 {object} apierror.Error // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error -// @Router /config/finding-sources/categories/{categoryId} [get] +// @Router /finding-sources/categories/{categoryId} [get] func (h *FindingSourceHandler) GetCategory(w http.ResponseWriter, r *http.Request) { categoryID := r.PathValue("categoryId") if categoryID == "" { @@ -312,7 +312,7 @@ func (h *FindingSourceHandler) GetCategory(w http.ResponseWriter, r *http.Reques // @Failure 400 {object} apierror.Error // @Failure 401 {object} apierror.Error // @Failure 500 {object} apierror.Error -// @Router /config/finding-sources [get] +// @Router /finding-sources [get] func (h *FindingSourceHandler) ListFindingSources(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() @@ -516,7 +516,7 @@ func (h *FindingSourceHandler) ListFindingSources(w http.ResponseWriter, r *http // @Failure 401 {object} apierror.Error // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error -// @Router /config/finding-sources/{id} [get] +// @Router /finding-sources/{id} [get] func (h *FindingSourceHandler) GetFindingSource(w http.ResponseWriter, r *http.Request) { findingSourceID := r.PathValue("id") if findingSourceID == "" { @@ -548,7 +548,7 @@ func (h *FindingSourceHandler) GetFindingSource(w http.ResponseWriter, r *http.R // @Failure 401 {object} apierror.Error // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error -// @Router /config/finding-sources/code/{code} [get] +// @Router /finding-sources/code/{code} [get] func (h *FindingSourceHandler) GetFindingSourceByCode(w http.ResponseWriter, r *http.Request) { code := r.PathValue("code") if code == "" { diff --git a/internal/infra/http/handler/group_handler.go b/internal/infra/http/handler/group_handler.go index fabba0c2..e25254d2 100644 --- a/internal/infra/http/handler/group_handler.go +++ b/internal/infra/http/handler/group_handler.go @@ -321,7 +321,7 @@ func (h *GroupHandler) handleServiceError(w http.ResponseWriter, err error) { // @Failure 400 {object} apierror.Error // @Failure 401 {object} apierror.Error // @Failure 403 {object} apierror.Error -// @Router /api/v1/groups [post] +// @Router /groups [post] func (h *GroupHandler) CreateGroup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -377,7 +377,7 @@ func (h *GroupHandler) CreateGroup(w http.ResponseWriter, r *http.Request) { // @Param groupId path string true "Group ID" // @Success 200 {object} GroupResponse // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId} [get] +// @Router /groups/{groupId} [get] func (h *GroupHandler) GetGroup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() tenantID := middleware.MustGetTenantID(r.Context()) @@ -415,7 +415,7 @@ func (h *GroupHandler) GetGroup(w http.ResponseWriter, r *http.Request) { // @Param limit query int false "Limit results" default(20) // @Param offset query int false "Offset for pagination" default(0) // @Success 200 {object} GroupListResponse -// @Router /api/v1/groups [get] +// @Router /groups [get] func (h *GroupHandler) ListGroups(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -505,7 +505,7 @@ func (h *GroupHandler) ListGroups(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} GroupResponse // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId} [put] +// @Router /groups/{groupId} [put] func (h *GroupHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -549,7 +549,7 @@ func (h *GroupHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) { // @Param groupId path string true "Group ID" // @Success 204 "No Content" // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId} [delete] +// @Router /groups/{groupId} [delete] func (h *GroupHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -576,7 +576,7 @@ func (h *GroupHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) { // @Param groupId path string true "Group ID" // @Success 200 {array} GroupMemberWithUserResponse // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/members [get] +// @Router /groups/{groupId}/members [get] func (h *GroupHandler) ListMembers(w http.ResponseWriter, r *http.Request) { ctx := r.Context() tenantID := middleware.MustGetTenantID(ctx) @@ -617,7 +617,7 @@ func (h *GroupHandler) ListMembers(w http.ResponseWriter, r *http.Request) { // @Success 201 {object} GroupMemberResponse // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/members [post] +// @Router /groups/{groupId}/members [post] func (h *GroupHandler) AddMember(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -670,7 +670,7 @@ func (h *GroupHandler) AddMember(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} GroupMemberResponse // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/members/{userId} [put] +// @Router /groups/{groupId}/members/{userId} [put] func (h *GroupHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -720,7 +720,7 @@ func (h *GroupHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) // @Success 204 "No Content" // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/members/{userId} [delete] +// @Router /groups/{groupId}/members/{userId} [delete] func (h *GroupHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -757,7 +757,7 @@ func (h *GroupHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { // @Success 204 "No Content" // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/permission-sets [post] +// @Router /groups/{groupId}/permission-sets [post] func (h *GroupHandler) AssignPermissionSet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -802,7 +802,7 @@ func (h *GroupHandler) AssignPermissionSet(w http.ResponseWriter, r *http.Reques // @Param permissionSetId path string true "Permission Set ID" // @Success 204 "No Content" // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/permission-sets/{permissionSetId} [delete] +// @Router /groups/{groupId}/permission-sets/{permissionSetId} [delete] func (h *GroupHandler) UnassignPermissionSet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -826,7 +826,7 @@ func (h *GroupHandler) UnassignPermissionSet(w http.ResponseWriter, r *http.Requ // @Param groupId path string true "Group ID" // @Success 200 {array} PermissionSetResponse // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/permission-sets [get] +// @Router /groups/{groupId}/permission-sets [get] func (h *GroupHandler) ListAssignedPermissionSets(w http.ResponseWriter, r *http.Request) { ctx := r.Context() tenantID := middleware.MustGetTenantID(ctx) @@ -876,7 +876,7 @@ func (h *GroupHandler) ListAssignedPermissionSets(w http.ResponseWriter, r *http // @Tags groups // @Produce json // @Success 200 {array} GroupWithRoleResponse -// @Router /api/v1/me/groups [get] +// @Router /me/groups [get] func (h *GroupHandler) ListMyGroups(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -945,7 +945,7 @@ type AssetBriefResponse struct { // @Success 204 "No Content" // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/assets [post] +// @Router /groups/{groupId}/assets [post] func (h *GroupHandler) AssignAsset(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -1050,7 +1050,7 @@ func (h *GroupHandler) BulkAssignAssets(w http.ResponseWriter, r *http.Request) // @Param assetId path string true "Asset ID" // @Success 204 "No Content" // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/assets/{assetId} [delete] +// @Router /groups/{groupId}/assets/{assetId} [delete] func (h *GroupHandler) UnassignAsset(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -1088,7 +1088,7 @@ type UpdateAssetOwnershipRequest struct { // @Success 204 "No Content" // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/assets/{assetId} [put] +// @Router /groups/{groupId}/assets/{assetId} [put] func (h *GroupHandler) UpdateAssetOwnership(w http.ResponseWriter, r *http.Request) { ctx := r.Context() groupID := chi.URLParam(r, "groupId") @@ -1129,7 +1129,7 @@ func (h *GroupHandler) UpdateAssetOwnership(w http.ResponseWriter, r *http.Reque // @Param groupId path string true "Group ID" // @Success 200 {array} GroupOwnershipResponse // @Failure 404 {object} apierror.Error -// @Router /api/v1/groups/{groupId}/assets [get] +// @Router /groups/{groupId}/assets [get] func (h *GroupHandler) ListGroupAssets(w http.ResponseWriter, r *http.Request) { ctx := r.Context() tenantID := middleware.MustGetTenantID(ctx) @@ -1185,7 +1185,7 @@ func (h *GroupHandler) ListGroupAssets(w http.ResponseWriter, r *http.Request) { // @Tags assets // @Produce json // @Success 200 {object} map[string]interface{} -// @Router /api/v1/me/assets [get] +// @Router /me/assets [get] func (h *GroupHandler) ListMyAssets(w http.ResponseWriter, r *http.Request) { ctx := r.Context() tenantID := middleware.MustGetTenantID(ctx) @@ -1228,7 +1228,7 @@ func (h *GroupHandler) ListMyAssets(w http.ResponseWriter, r *http.Request) { // @Produce json // @Success 200 {object} map[string]interface{} // @Failure 500 {object} apierror.Error -// @Router /api/v1/groups/sync [post] +// @Router /groups/sync [post] func (h *GroupHandler) TriggerSync(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/infra/http/handler/health_handler.go b/internal/infra/http/handler/health_handler.go index cfbfa425..65eeabf5 100644 --- a/internal/infra/http/handler/health_handler.go +++ b/internal/infra/http/handler/health_handler.go @@ -52,6 +52,12 @@ type HealthResponse struct { } // Health handles the /health endpoint (liveness probe). +// +// Registered on the ROOT router, not under /api/v1 — probes must not require +// auth or a version prefix. Swagger 2.0 has no per-operation basePath, so the +// generated spec renders this as /api/v1/health. That is the one known place +// where the spec cannot be literally true; the real path is /health. Ready +// below has the same caveat. // @Summary Health check // @Description Returns the health status of the service (liveness probe) // @Tags Health diff --git a/internal/infra/http/handler/ingest_handler.go b/internal/infra/http/handler/ingest_handler.go index 7dabf7e0..eeb9896d 100644 --- a/internal/infra/http/handler/ingest_handler.go +++ b/internal/infra/http/handler/ingest_handler.go @@ -678,7 +678,7 @@ func (h *IngestHandler) RenewKey(w http.ResponseWriter, r *http.Request) { // @Failure 401 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security ApiKeyAuth -// @Router /ingest/check [post] +// @Router /agent/ingest/check [post] func (h *IngestHandler) CheckFingerprints(w http.ResponseWriter, r *http.Request) { agt := AgentFromContext(r.Context()) if agt == nil { @@ -738,7 +738,7 @@ type BaselineDiffRequest struct { // @Produce json // @Param request body BaselineDiffRequest true "Repository, base branch, fingerprints" // @Success 200 {object} ingest.BaselineDiffOutput -// @Router /agent/ingest/new-vs-base [post] +// @Router /agent/ingest/baseline-diff [post] func (h *IngestHandler) BaselineDiff(w http.ResponseWriter, r *http.Request) { agt := AgentFromContext(r.Context()) if agt == nil { diff --git a/internal/infra/http/handler/integration_handler.go b/internal/infra/http/handler/integration_handler.go index 9e4f0813..1b31916f 100644 --- a/internal/infra/http/handler/integration_handler.go +++ b/internal/infra/http/handler/integration_handler.go @@ -1440,22 +1440,19 @@ type SendNotificationRequest struct { Fields map[string]string `json:"fields"` } -// SendNotification handles POST /api/v1/integrations/{id}/send -// @Summary Send notification -// @Description Sends a notification through the specified integration -// @Tags Integrations -// @Accept json -// @Produce json -// @Param id path string true "Integration ID" format(uuid) -// @Param request body SendNotificationRequest true "Notification content" -// @Success 200 {object} map[string]any "Send result" -// @Failure 400 {object} map[string]string "Bad request" -// @Failure 401 {object} map[string]string "Unauthorized" -// @Failure 403 {object} map[string]string "Forbidden" -// @Failure 404 {object} map[string]string "Not found" -// @Failure 500 {object} map[string]string "Internal server error" -// @Security BearerAuth -// @Router /integrations/{id}/send [post] +// SendNotification would handle POST /api/v1/integrations/{id}/send. +// +// UNROUTED. This method is complete — it resolves the tenant, validates the +// body and calls integration.Service.SendNotification — but it is registered +// nowhere in internal/infra/http/routes, so the endpoint 404s. The @Router +// annotation was removed rather than kept, because the spec is now a generated +// contract the UI reads: advertising a path that does not exist is how the UI +// came to call GET /me/event-types against a server that never had it. +// +// Wiring it up is a behavior change (a new authenticated write path that can +// emit outbound traffic) and belongs in its own reviewed change, not in a spec +// regeneration. Until then this stays dead and undocumented rather than dead +// and documented. func (h *IntegrationHandler) SendNotification(w http.ResponseWriter, r *http.Request) { tenantID := middleware.MustGetTenantID(r.Context()) diff --git a/internal/infra/http/handler/mcp_handler_test.go b/internal/infra/http/handler/mcp_handler_test.go index ad7e0a10..217f8740 100644 --- a/internal/infra/http/handler/mcp_handler_test.go +++ b/internal/infra/http/handler/mcp_handler_test.go @@ -384,6 +384,10 @@ func (m *fakeAuditRepo) GetByID(_ context.Context, _ shared.ID) (*auditdom.Audit func (m *fakeAuditRepo) GetByTenantAndID(_ context.Context, _, _ shared.ID) (*auditdom.AuditLog, error) { return nil, nil } + +func (m *fakeAuditRepo) GetSystemByID(_ context.Context, id shared.ID) (*auditdom.AuditLog, error) { + return nil, auditdom.AuditLogNotFoundError(id) +} func (m *fakeAuditRepo) List(_ context.Context, _ auditdom.Filter, _ pagination.Pagination) (pagination.Result[*auditdom.AuditLog], error) { return pagination.Result[*auditdom.AuditLog]{}, nil } diff --git a/internal/infra/http/handler/permission_set_handler.go b/internal/infra/http/handler/permission_set_handler.go index aa64b593..109c74b4 100644 --- a/internal/infra/http/handler/permission_set_handler.go +++ b/internal/infra/http/handler/permission_set_handler.go @@ -219,7 +219,7 @@ func (h *PermissionSetHandler) handleServiceError(w http.ResponseWriter, err err // @Failure 400 {object} apierror.Error // @Failure 401 {object} apierror.Error // @Failure 403 {object} apierror.Error -// @Router /api/v1/permission-sets [post] +// @Router /permission-sets [post] func (h *PermissionSetHandler) CreatePermissionSet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -267,7 +267,7 @@ func (h *PermissionSetHandler) CreatePermissionSet(w http.ResponseWriter, r *htt // @Param id path string true "Permission set ID" // @Success 200 {object} PermissionSetWithItemsResponse // @Failure 404 {object} apierror.Error -// @Router /api/v1/permission-sets/{id} [get] +// @Router /permission-sets/{id} [get] func (h *PermissionSetHandler) GetPermissionSet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -307,7 +307,7 @@ func (h *PermissionSetHandler) GetPermissionSet(w http.ResponseWriter, r *http.R // @Success 200 {object} PermissionSetResponse // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/permission-sets/{id} [put] +// @Router /permission-sets/{id} [put] func (h *PermissionSetHandler) UpdatePermissionSet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -354,7 +354,7 @@ func (h *PermissionSetHandler) UpdatePermissionSet(w http.ResponseWriter, r *htt // @Success 204 // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/permission-sets/{id} [delete] +// @Router /permission-sets/{id} [delete] func (h *PermissionSetHandler) DeletePermissionSet(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -385,7 +385,7 @@ func (h *PermissionSetHandler) DeletePermissionSet(w http.ResponseWriter, r *htt // @Param limit query int false "Limit results" default(20) // @Param offset query int false "Offset for pagination" default(0) // @Success 200 {object} PermissionSetListResponse -// @Router /api/v1/permission-sets [get] +// @Router /permission-sets [get] func (h *PermissionSetHandler) ListPermissionSets(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -455,7 +455,7 @@ func (h *PermissionSetHandler) ListPermissionSets(w http.ResponseWriter, r *http // @Success 201 // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/permission-sets/{id}/permissions [post] +// @Router /permission-sets/{id}/permissions [post] func (h *PermissionSetHandler) AddPermission(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -501,7 +501,7 @@ func (h *PermissionSetHandler) AddPermission(w http.ResponseWriter, r *http.Requ // @Success 204 // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error -// @Router /api/v1/permission-sets/{id}/permissions/{permissionId} [delete] +// @Router /permission-sets/{id}/permissions/{permissionId} [delete] func (h *PermissionSetHandler) RemovePermission(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -537,7 +537,7 @@ func (h *PermissionSetHandler) RemovePermission(w http.ResponseWriter, r *http.R // @Tags permission-sets // @Produce json // @Success 200 {array} PermissionSetResponse -// @Router /api/v1/permission-sets/system [get] +// @Router /permission-sets/system [get] func (h *PermissionSetHandler) ListSystemPermissionSets(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -586,7 +586,7 @@ type EffectivePermissionsResponse struct { // @Tags permissions // @Produce json // @Success 200 {object} EffectivePermissionsResponse -// @Router /api/v1/me/permissions [get] +// @Router /me/permissions [get] func (h *PermissionSetHandler) GetMyEffectivePermissions(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/infra/http/handler/runtime_telemetry_handler.go b/internal/infra/http/handler/runtime_telemetry_handler.go index d5efd6a9..302ac819 100644 --- a/internal/infra/http/handler/runtime_telemetry_handler.go +++ b/internal/infra/http/handler/runtime_telemetry_handler.go @@ -51,6 +51,14 @@ type runtimeEventIn struct { Severity string `json:"severity,omitempty"` // info|low|medium|high|critical, default info ObservedAt time.Time `json:"observed_at"` // when the event happened on the endpoint Properties map[string]any `json:"properties,omitempty"` + + // CorrelationID optionally ties this event to the validation job / + // command that provoked it. A producer that knows which activity it + // is reacting to should stamp it: the Stage-4 detection correlator + // then matches exactly instead of falling back to an asset+time + // window heuristic. Not FK-enforced — telemetry can outlive the + // command row. + CorrelationID string `json:"correlation_id,omitempty"` } // ingestRequest supports both single-event and batched submissions. A @@ -61,8 +69,26 @@ type ingestRequest struct { } type ingestResponse struct { - Accepted int `json:"accepted"` - Rejected int `json:"rejected"` + Accepted int `json:"accepted"` + Rejected int `json:"rejected"` + + // Unpaired counts ACCEPTED events that carried no endpoint_asset_id. + // They are stored and the IOC correlator still matches them, because it + // keys on values inside the event. They are invisible to every + // asset-scoped read: Stage-4 detection correlation's heuristic fallback + // and the per-asset Stage-6 dashboards. + // + // This is permanent, not a pending state. There is no server-side way to + // fill it in later — `agents` has no asset column and `assets` has no + // agent column, and only the producer knows which endpoint an event + // describes anyway (a forwarder reports on many hosts). Migration 000155 + // once promised a nightly reconciler; it was never written and could not + // have been. + // + // Reported so a producer sees the degradation on the response it already + // reads, rather than discovering months later that half the feature never + // applied to its data. + Unpaired int `json:"unpaired"` Errors []string `json:"errors,omitempty"` } @@ -104,8 +130,8 @@ func (h *RuntimeTelemetryHandler) Ingest(w http.ResponseWriter, r *http.Request) // records to the originating telemetry event. const q = ` INSERT INTO runtime_telemetry_events - (tenant_id, agent_id, endpoint_asset_id, event_type, severity, observed_at, properties) - VALUES ($1, $2, NULLIF($3,'')::uuid, $4, COALESCE(NULLIF($5,''),'info'), $6, $7) + (tenant_id, agent_id, endpoint_asset_id, event_type, severity, observed_at, properties, correlation_id) + VALUES ($1, $2, NULLIF($3,'')::uuid, $4, COALESCE(NULLIF($5,''),'info'), $6, $7, NULLIF($8,'')::uuid) RETURNING id ` @@ -160,6 +186,16 @@ func (h *RuntimeTelemetryHandler) Ingest(w http.ResponseWriter, r *http.Request) continue } } + // Validate correlation_id in Go rather than letting the ::uuid + // cast blow up: a malformed value would otherwise surface as an + // opaque "database insert failed" for the whole event. + if ev.CorrelationID != "" { + if _, cerr := shared.IDFromString(ev.CorrelationID); cerr != nil { + resp.Rejected++ + resp.Errors = append(resp.Errors, eventErr(i, "correlation_id must be a UUID")) + continue + } + } propsJSON, err := json.Marshal(nilMapToEmpty(ev.Properties)) if err != nil { resp.Rejected++ @@ -175,6 +211,7 @@ func (h *RuntimeTelemetryHandler) Ingest(w http.ResponseWriter, r *http.Request) ev.Severity, ev.ObservedAt.UTC(), propsJSON, + ev.CorrelationID, ).Scan(&eventIDStr) if err != nil { h.logger.Warn("runtime telemetry insert failed", @@ -188,6 +225,9 @@ func (h *RuntimeTelemetryHandler) Ingest(w http.ResponseWriter, r *http.Request) continue } resp.Accepted++ + if ev.EndpointAssetID == "" { + resp.Unpaired++ + } if eventID, parseErr := shared.IDFromString(eventIDStr); parseErr == nil { accepted = append(accepted, iocapp.TelemetryEvent{ @@ -198,6 +238,20 @@ func (h *RuntimeTelemetryHandler) Ingest(w http.ResponseWriter, r *http.Request) } } + // Surface the degradation in the logs too. A producer that never sends + // endpoint_asset_id gets a fully successful 200 with a healthy accepted + // count, and would have no reason to suspect that asset-scoped correlation + // silently does not apply to any of its data. + if resp.Unpaired > 0 { + h.logger.Warn("runtime telemetry accepted without an endpoint asset link", + "tenant_id", agt.TenantID.String(), + "agent_id", agt.ID.String(), + "unpaired", resp.Unpaired, + "accepted", resp.Accepted, + "impact", "invisible to asset-scoped detection correlation and per-asset dashboards; "+ + "the producer must supply endpoint_asset_id, the server cannot infer it") + } + // B6 wire: ONE batch correlate call for the whole accepted slice. // Correlator dedups candidates internally and runs a single // FindActiveByValues query; errors here are logged but never block diff --git a/internal/infra/http/handler/runtime_telemetry_unpaired_test.go b/internal/infra/http/handler/runtime_telemetry_unpaired_test.go new file mode 100644 index 00000000..35ab66a3 --- /dev/null +++ b/internal/infra/http/handler/runtime_telemetry_unpaired_test.go @@ -0,0 +1,212 @@ +package handler + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + _ "github.com/lib/pq" + + "github.com/openctemio/api/pkg/domain/agent" + "github.com/openctemio/api/pkg/domain/shared" + "github.com/openctemio/api/pkg/logger" +) + +// endpoint_asset_id is nullable, and nothing will ever fill it in later. +// +// Migration 000155 promised "a nightly reconciler job pairs events with assets +// by agent_id". That job was never written and could not be: `agents` has no +// asset column, `assets` has no agent column, and there is no join table — so +// there is no key to pair BY. It is also the wrong idea, because only the +// producer knows which endpoint an event describes; an EDR/XDR forwarder +// reports on many hosts. +// +// So an event without an asset link is permanently invisible to every +// asset-scoped read — Stage-4 detection correlation's heuristic fallback and +// the per-asset Stage-6 dashboards — while the response says "accepted" and the +// IOC correlator, which keys on values inside the event, still works. That is a +// half-working feature that looks fully working. +// +// These tests pin the counter that makes it visible. + +func openTelemetryDB(t *testing.T) *sql.DB { + t.Helper() + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + t.Skip("DATABASE_URL not set; skipping telemetry ingest tests") + } + db, err := sql.Open("postgres", dbURL) + if err != nil { + t.Skipf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.PingContext(context.Background()); err != nil { + t.Skipf("cannot reach DATABASE_URL: %v", err) + } + return db +} + +func seedTelemetryTenant(t *testing.T, db *sql.DB) shared.ID { + t.Helper() + + id := shared.NewID() + if _, err := db.ExecContext(context.Background(), + `INSERT INTO tenants (id, name, slug) VALUES ($1, $2, $3)`, + id.String(), "telemetry unpaired test", "tel-"+id.String()); err != nil { + t.Fatalf("seed tenant: %v", err) + } + t.Cleanup(func() { + _, _ = db.ExecContext(context.Background(), `DELETE FROM tenants WHERE id = $1`, id.String()) + }) + return id +} + +func seedTelemetryAsset(t *testing.T, db *sql.DB, tenantID shared.ID) shared.ID { + t.Helper() + + id := shared.NewID() + if _, err := db.ExecContext(context.Background(), + `INSERT INTO assets (id, tenant_id, name, asset_type) VALUES ($1, $2, $3, 'host')`, + id.String(), tenantID.String(), "host-"+id.String()); err != nil { + t.Fatalf("seed asset: %v", err) + } + return id +} + +// ingestEvents posts a batch as an authenticated tenant agent and returns the +// decoded response. +func ingestEvents(t *testing.T, db *sql.DB, tenantID shared.ID, events []map[string]any) ingestResponse { + t.Helper() + + body, err := json.Marshal(map[string]any{"events": events}) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + h := NewRuntimeTelemetryHandler(db, logger.NewNop()) + + r := httptest.NewRequest(http.MethodPost, "/api/v1/telemetry-events", bytes.NewReader(body)) + tid := tenantID + agt := &agent.Agent{ID: shared.NewID(), TenantID: &tid, Status: agent.AgentStatusActive} + r = r.WithContext(context.WithValue(r.Context(), agentContextKey, agt)) + + w := httptest.NewRecorder() + h.Ingest(w, r) + + // 202 on a fully-accepted batch, 207 when some events were rejected. + if w.Code != http.StatusAccepted && w.Code != http.StatusMultiStatus { + t.Fatalf("ingest returned %d: %s", w.Code, w.Body.String()) + } + + var resp ingestResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + t.Cleanup(func() { + _, _ = db.ExecContext(context.Background(), + `DELETE FROM runtime_telemetry_events WHERE tenant_id = $1`, tenantID.String()) + }) + return resp +} + +func event(assetID string) map[string]any { + e := map[string]any{ + "event_type": "network_connect", + "observed_at": time.Now().UTC().Format(time.RFC3339), + } + if assetID != "" { + e["endpoint_asset_id"] = assetID + } + return e +} + +// The core case: a producer that never sends endpoint_asset_id gets a fully +// successful response. Without the counter there is nothing in that response to +// tell it half the feature does not apply. +func TestIngest_ReportsUnpairedEvents(t *testing.T) { + db := openTelemetryDB(t) + tenantID := seedTelemetryTenant(t, db) + + resp := ingestEvents(t, db, tenantID, []map[string]any{ + event(""), event(""), event(""), + }) + + if resp.Accepted != 3 { + t.Fatalf("accepted = %d, want 3 (errors: %v)", resp.Accepted, resp.Errors) + } + if resp.Rejected != 0 { + t.Fatalf("rejected = %d, want 0: unpaired events are stored, not refused", resp.Rejected) + } + if resp.Unpaired != 3 { + t.Fatalf("unpaired = %d, want 3: the response claims full success while every "+ + "event is invisible to asset-scoped correlation", resp.Unpaired) + } +} + +// A producer doing it right must not be told it has a problem. +func TestIngest_PairedEventsAreNotCountedUnpaired(t *testing.T) { + db := openTelemetryDB(t) + tenantID := seedTelemetryTenant(t, db) + assetID := seedTelemetryAsset(t, db, tenantID) + + resp := ingestEvents(t, db, tenantID, []map[string]any{ + event(assetID.String()), event(assetID.String()), + }) + + if resp.Accepted != 2 { + t.Fatalf("accepted = %d, want 2 (errors: %v)", resp.Accepted, resp.Errors) + } + if resp.Unpaired != 0 { + t.Fatalf("unpaired = %d, want 0: these events carry a valid asset link", resp.Unpaired) + } +} + +// A mixed batch is the realistic case — the count must be per-event, not a +// boolean about the batch. +func TestIngest_CountsUnpairedPerEvent(t *testing.T) { + db := openTelemetryDB(t) + tenantID := seedTelemetryTenant(t, db) + assetID := seedTelemetryAsset(t, db, tenantID) + + resp := ingestEvents(t, db, tenantID, []map[string]any{ + event(assetID.String()), event(""), event(assetID.String()), event(""), + }) + + if resp.Accepted != 4 { + t.Fatalf("accepted = %d, want 4 (errors: %v)", resp.Accepted, resp.Errors) + } + if resp.Unpaired != 2 { + t.Fatalf("unpaired = %d, want 2", resp.Unpaired) + } +} + +// Unpaired counts ACCEPTED events only. A rejected event was never stored, so +// counting it here would overstate the gap and send a producer looking for a +// configuration problem that is really a validation error. +func TestIngest_RejectedEventsAreNotCountedUnpaired(t *testing.T) { + db := openTelemetryDB(t) + tenantID := seedTelemetryTenant(t, db) + + resp := ingestEvents(t, db, tenantID, []map[string]any{ + {"observed_at": time.Now().UTC().Format(time.RFC3339)}, // no event_type -> rejected + event(""), // accepted, unpaired + }) + + if resp.Rejected != 1 { + t.Fatalf("rejected = %d, want 1", resp.Rejected) + } + if resp.Accepted != 1 { + t.Fatalf("accepted = %d, want 1", resp.Accepted) + } + if resp.Unpaired != 1 { + t.Fatalf("unpaired = %d, want 1: a rejected event was never stored and must "+ + "not be reported as an unpaired one", resp.Unpaired) + } +} diff --git a/internal/infra/http/handler/secretstore_handler.go b/internal/infra/http/handler/secretstore_handler.go index 873b94f8..4e81d5b7 100644 --- a/internal/infra/http/handler/secretstore_handler.go +++ b/internal/infra/http/handler/secretstore_handler.go @@ -147,7 +147,7 @@ type ListCredentialsResponse struct { // @Failure 409 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /credentials [post] +// @Router /secret-store [post] func (h *SecretStoreHandler) Create(w http.ResponseWriter, r *http.Request) { var req CreateCredentialRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -224,7 +224,7 @@ func (h *SecretStoreHandler) Create(w http.ResponseWriter, r *http.Request) { // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /credentials/{id} [get] +// @Router /secret-store/{id} [get] func (h *SecretStoreHandler) Get(w http.ResponseWriter, r *http.Request) { credentialID := chi.URLParam(r, "id") if credentialID == "" { @@ -262,7 +262,7 @@ func (h *SecretStoreHandler) Get(w http.ResponseWriter, r *http.Request) { // @Failure 400 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /credentials [get] +// @Router /secret-store [get] func (h *SecretStoreHandler) List(w http.ResponseWriter, r *http.Request) { tenantIDStr := middleware.GetTenantID(r.Context()) tenantID, err := shared.IDFromString(tenantIDStr) @@ -328,7 +328,7 @@ func (h *SecretStoreHandler) List(w http.ResponseWriter, r *http.Request) { // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /credentials/{id} [put] +// @Router /secret-store/{id} [put] func (h *SecretStoreHandler) Update(w http.ResponseWriter, r *http.Request) { credentialID := chi.URLParam(r, "id") if credentialID == "" { @@ -393,7 +393,7 @@ func (h *SecretStoreHandler) Update(w http.ResponseWriter, r *http.Request) { // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /credentials/{id} [delete] +// @Router /secret-store/{id} [delete] func (h *SecretStoreHandler) Delete(w http.ResponseWriter, r *http.Request) { credentialID := chi.URLParam(r, "id") if credentialID == "" { diff --git a/internal/infra/http/handler/tool_handler.go b/internal/infra/http/handler/tool_handler.go index 560b354f..d78dc1bf 100644 --- a/internal/infra/http/handler/tool_handler.go +++ b/internal/infra/http/handler/tool_handler.go @@ -1061,7 +1061,7 @@ func (h *ToolHandler) GetEffectiveConfig(w http.ResponseWriter, r *http.Request) // @Failure 400 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /tenant-tools/bulk-enable [post] +// @Router /tenant-tools/bulk/enable [post] func (h *ToolHandler) BulkEnable(w http.ResponseWriter, r *http.Request) { tenantID := middleware.GetTenantID(r.Context()) @@ -1100,7 +1100,7 @@ func (h *ToolHandler) BulkEnable(w http.ResponseWriter, r *http.Request) { // @Failure 400 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /tenant-tools/bulk-disable [post] +// @Router /tenant-tools/bulk/disable [post] func (h *ToolHandler) BulkDisable(w http.ResponseWriter, r *http.Request) { tenantID := middleware.GetTenantID(r.Context()) @@ -1250,7 +1250,7 @@ func (h *ToolHandler) GetToolWithConfig(w http.ResponseWriter, r *http.Request) // @Failure 400 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /tool-stats [get] +// @Router /tenant-tools/stats [get] func (h *ToolHandler) GetTenantStats(w http.ResponseWriter, r *http.Request) { tenantID := middleware.GetTenantID(r.Context()) days := parseQueryInt(r.URL.Query().Get("days"), 30) @@ -1282,14 +1282,14 @@ func (h *ToolHandler) GetTenantStats(w http.ResponseWriter, r *http.Request) { // @Tags Tool Stats // @Accept json // @Produce json -// @Param tool_id path string true "Tool ID" +// @Param toolId path string true "Tool ID" // @Param days query int false "Number of days to include" default(30) // @Success 200 {object} ToolStatsResponse // @Failure 400 {object} apierror.Error // @Failure 404 {object} apierror.Error // @Failure 500 {object} apierror.Error // @Security BearerAuth -// @Router /tool-stats/{tool_id} [get] +// @Router /tenant-tools/stats/{toolId} [get] func (h *ToolHandler) GetToolStats(w http.ResponseWriter, r *http.Request) { toolID := chi.URLParam(r, "toolId") tenantID := middleware.GetTenantID(r.Context()) diff --git a/internal/infra/http/handler/validation_handler.go b/internal/infra/http/handler/validation_handler.go index 93251dff..eda80ee2 100644 --- a/internal/infra/http/handler/validation_handler.go +++ b/internal/infra/http/handler/validation_handler.go @@ -190,6 +190,23 @@ type storedEvidenceOut struct { StartedAt time.Time `json:"started_at,omitempty"` EndedAt time.Time `json:"ended_at,omitempty"` CreatedAt time.Time `json:"created_at"` + + // DetectionStatus answers "did any control observe this validation?" — + // a DIFFERENT question from Outcome ("is the exposure still + // reachable?"). The value sets are disjoint on purpose; see + // internal/app/validation/detection.go. + // + // Clients MUST NOT render anything other than "not_observed" as a + // control failure. "no_telemetry_source" in particular means no + // telemetry is reaching the platform — UNKNOWN, not a miss. Showing + // it as a miss reports a configuration gap as a security failure. + DetectionStatus string `json:"detection_status"` + // DetectionIsGap is the precomputed safe predicate for the above so a + // client cannot get the comparison wrong. + DetectionIsGap bool `json:"detection_is_gap"` + // DetectionDetail explains how the verdict was reached (match mode, + // window bounds, pipeline liveness). + DetectionDetail map[string]any `json:"detection_detail,omitempty"` } // ListFindingEvidence handles GET /api/v1/findings/{id}/evidence (JWT auth). @@ -226,6 +243,10 @@ func (h *ValidationHandler) ListFindingEvidence(w http.ResponseWriter, r *http.R StartedAt: rec.Evidence.StartedAt, EndedAt: rec.Evidence.EndedAt, CreatedAt: rec.CreatedAt, + + DetectionStatus: string(rec.DetectionStatus), + DetectionIsGap: rec.DetectionStatus.IsDetectionGap(), + DetectionDetail: rec.DetectionDetail, } if rec.SimulationRunID != nil { item.SimulationRunID = rec.SimulationRunID.String() diff --git a/internal/infra/postgres/agent_undead_dispatch_db_test.go b/internal/infra/postgres/agent_undead_dispatch_db_test.go index 238b00a7..d8268abe 100644 --- a/internal/infra/postgres/agent_undead_dispatch_db_test.go +++ b/internal/infra/postgres/agent_undead_dispatch_db_test.go @@ -93,6 +93,7 @@ func agentHealth(ctx context.Context, t *testing.T, db *sql.DB, id shared.ID) st func TestMarkStaleAsOffline_ReapsNeverHeartbeatedAgent(t *testing.T) { db := openAgentDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := &AgentRepository{db: &DB{DB: db}} tenantID := seedTestTenant(ctx, t, db) @@ -110,6 +111,7 @@ func TestMarkStaleAsOffline_ReapsNeverHeartbeatedAgent(t *testing.T) { func TestMarkStaleAgentsOffline_ReapsNeverHeartbeatedAgent(t *testing.T) { db := openAgentDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := &AgentRepository{db: &DB{DB: db}} tenantID := seedTestTenant(ctx, t, db) @@ -139,6 +141,7 @@ func TestMarkStaleAgentsOffline_ReapsNeverHeartbeatedAgent(t *testing.T) { func TestMarkStaleSweeps_LeaveLiveAgentAlone(t *testing.T) { db := openAgentDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := &AgentRepository{db: &DB{DB: db}} tenantID := seedTestTenant(ctx, t, db) @@ -163,6 +166,7 @@ func TestMarkStaleSweeps_LeaveLiveAgentAlone(t *testing.T) { func TestFindAvailableWithTool_SkipsNeverHeartbeatedAgent(t *testing.T) { db := openAgentDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := &AgentRepository{db: &DB{DB: db}} tenantID := seedTestTenant(ctx, t, db) @@ -190,6 +194,7 @@ func TestFindAvailableWithTool_SkipsNeverHeartbeatedAgent(t *testing.T) { func TestGetAvailableToolsForTenant_IgnoresNeverHeartbeatedAgent(t *testing.T) { db := openAgentDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := &AgentRepository{db: &DB{DB: db}} tenantID := seedTestTenant(ctx, t, db) diff --git a/internal/infra/postgres/audit_repository.go b/internal/infra/postgres/audit_repository.go index e5c6aa10..29106538 100644 --- a/internal/infra/postgres/audit_repository.go +++ b/internal/infra/postgres/audit_repository.go @@ -166,6 +166,15 @@ func (r *AuditRepository) GetByTenantAndID(ctx context.Context, tenantID, id sha return r.scanAuditLog(row, audit.AuditLogNotFoundError(id)) } +// GetSystemByID returns a tenant-less audit log by id — the rows on the +// SystemChainTenantID chain. `tenant_id IS NULL` is part of the query, not a +// caller's responsibility, so this can never return a tenant's row. +func (r *AuditRepository) GetSystemByID(ctx context.Context, id shared.ID) (*audit.AuditLog, error) { + query := r.selectQuery() + " WHERE tenant_id IS NULL AND id = $1" + row := r.db.QueryRowContext(ctx, query, id.String()) + return r.scanAuditLog(row, audit.AuditLogNotFoundError(id)) +} + // List retrieves audit logs matching the filter with pagination. func (r *AuditRepository) List(ctx context.Context, filter audit.Filter, page pagination.Pagination) (pagination.Result[*audit.AuditLog], error) { baseQuery := r.selectQuery() diff --git a/internal/infra/postgres/command_repository.go b/internal/infra/postgres/command_repository.go index 7169de94..deedcf5c 100644 --- a/internal/infra/postgres/command_repository.go +++ b/internal/infra/postgres/command_repository.go @@ -291,25 +291,6 @@ func (r *CommandRepository) Delete(ctx context.Context, id shared.ID) error { return nil } -// ExpireOldCommands expires commands that have passed their expiration time. -func (r *CommandRepository) ExpireOldCommands(ctx context.Context) (int64, error) { - query := ` - UPDATE commands - SET status = 'expired' - WHERE status = 'pending' - AND expires_at IS NOT NULL - AND expires_at < NOW() - ` - - result, err := r.db.ExecContext(ctx, query) - if err != nil { - return 0, fmt.Errorf("failed to expire commands: %w", err) - } - - rowsAffected, _ := result.RowsAffected() - return rowsAffected, nil -} - func (r *CommandRepository) selectQuery() string { return ` SELECT id, tenant_id, agent_id, type, priority, payload, diff --git a/internal/infra/postgres/global_sweep_lock_db_test.go b/internal/infra/postgres/global_sweep_lock_db_test.go new file mode 100644 index 00000000..43453b17 --- /dev/null +++ b/internal/infra/postgres/global_sweep_lock_db_test.go @@ -0,0 +1,80 @@ +package postgres + +import ( + "context" + "database/sql" + "testing" +) + +// Several repository sweeps are deliberately GLOBAL — RecoverStuckJobs, +// ExpireOldPlatformJobs, MarkStaleAsOffline and the recover_stuck_* SQL +// functions all operate across every tenant, because that is what a background +// reaper does. +// +// That makes them untestable in parallel against a shared database, and two +// packages share one: internal/infra/postgres and tests/integration. `go test +// ./...` runs their binaries concurrently. +// +// The collision is mechanically certain but has NOT been observed failing. +// platform_job_lifecycle_db_test seeds a non-platform command, acknowledged, 120 +// minutes old, with a tenant agent, and asserts the PLATFORM sweep leaves it +// alone. `recover_stuck_tenant_commands(10, 3)`, called from +// tests/integration/command_recovery_test, selects on exactly those columns: +// is_platform_job = FALSE, status = 'acknowledged', agent_id IS NOT NULL, +// acknowledged_at older than the threshold, dispatch_attempts under the cap. Run +// against that row by hand it returns 1 and rewrites it to pending/1 — precisely +// what the assertion forbids. +// +// What has not happened is the two landing together by chance: 120 concurrent +// rounds of both packages produced no failure, because each test seeds, sweeps +// and asserts inside ~20-80ms. So this is a latent hazard, not a flake anyone is +// currently suffering. It is worth closing anyway — the window widens with -race +// (which CI uses), with a loaded runner, and with every test added to either +// package — but it should not be sold as a fix for observed CI noise. +// +// A serializing lock is the honest fix. Weakening the assertions would remove +// the thing they exist to catch, and `-p 1` would serialize 91 packages to +// discipline two. +// +// The same helper exists in tests/integration. Keeping a copy rather than +// introducing a shared testutil package is deliberate: it is nine lines, and the +// lock key is the contract between them — that must be identical, and it is +// easier to see that when both files state it. +const globalSweepLockKey = 8_845_120_301 // arbitrary, must match tests/integration + +// lockGlobalSweep serializes a test that runs a cross-tenant sweep against every +// other such test, in this package and in tests/integration. Call it once at the +// top of the test and defer the returned release: +// +// defer lockGlobalSweep(ctx, t, db)() +// +// It must wrap the WHOLE test, not just the sweep call. The race is between one +// package's seed and its assertion: a sweep from the other package landing in +// that window recovers the row out from under it. Locking only the sweep call +// would leave exactly that window open. +func lockGlobalSweep(ctx context.Context, t *testing.T, db *sql.DB) func() { + t.Helper() + + conn, err := db.Conn(ctx) + if err != nil { + t.Fatalf("global sweep lock: acquire connection: %v", err) + } + + // Session-level, not transaction-level: the sweeps under test run their own + // statements on other pool connections, so the lock must outlive any single + // transaction. + if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", globalSweepLockKey); err != nil { + _ = conn.Close() + t.Fatalf("global sweep lock: %v", err) + } + + return func() { + // context.Background(): the test's context may already be canceled, and + // failing to unlock would block every later sweep test in the run. + if _, err := conn.ExecContext(context.Background(), + "SELECT pg_advisory_unlock($1)", globalSweepLockKey); err != nil { + t.Errorf("global sweep unlock: %v — later tests in this run may block", err) + } + _ = conn.Close() + } +} diff --git a/internal/infra/postgres/platform_job_lifecycle_db_test.go b/internal/infra/postgres/platform_job_lifecycle_db_test.go index 5c21bc19..80b20494 100644 --- a/internal/infra/postgres/platform_job_lifecycle_db_test.go +++ b/internal/infra/postgres/platform_job_lifecycle_db_test.go @@ -162,6 +162,7 @@ func commandState(ctx context.Context, t *testing.T, db *sql.DB, id shared.ID) ( func TestRecoverStuckJobs_RecoversJobClaimedByTenantAgent(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) @@ -196,6 +197,7 @@ func TestRecoverStuckJobs_RecoversJobClaimedByTenantAgent(t *testing.T) { func TestRecoverStuckJobs_RecoversJobClaimedByPlatformAgent(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) @@ -225,6 +227,7 @@ func TestRecoverStuckJobs_RecoversJobClaimedByPlatformAgent(t *testing.T) { func TestRecoverStuckJobs_HonoursMaxRetries(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) @@ -259,6 +262,7 @@ func TestRecoverStuckJobs_HonoursMaxRetries(t *testing.T) { func TestRecoverStuckJobs_IgnoresFreshlyAcknowledgedJob(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) @@ -285,6 +289,7 @@ func TestRecoverStuckJobs_IgnoresFreshlyAcknowledgedJob(t *testing.T) { func TestRecoverStuckJobs_IgnoresTenantCommands(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) @@ -315,6 +320,7 @@ func TestRecoverStuckJobs_IgnoresTenantCommands(t *testing.T) { func TestFindQueueExpiredPlatformJobs_ReturnsOverdueQueuedJob(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) @@ -349,6 +355,7 @@ func TestFindQueueExpiredPlatformJobs_ReturnsOverdueQueuedJob(t *testing.T) { func TestFindQueueExpiredPlatformJobs_IgnoresJobWithinBudget(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) @@ -375,6 +382,7 @@ func TestFindQueueExpiredPlatformJobs_IgnoresJobWithinBudget(t *testing.T) { func TestFindQueueExpiredPlatformJobs_IgnoresAcknowledgedJob(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) @@ -402,6 +410,7 @@ func TestFindQueueExpiredPlatformJobs_IgnoresAcknowledgedJob(t *testing.T) { func TestFindQueueExpiredPlatformJobs_IgnoresTenantCommands(t *testing.T) { db := openPlatformJobDB(t) ctx := context.Background() + defer lockGlobalSweep(ctx, t, db)() repo := NewCommandRepository(&DB{DB: db}) tenantID := seedTestTenant(ctx, t, db) diff --git a/internal/infra/postgres/telemetry_probe_repository.go b/internal/infra/postgres/telemetry_probe_repository.go new file mode 100644 index 00000000..fad582c2 --- /dev/null +++ b/internal/infra/postgres/telemetry_probe_repository.go @@ -0,0 +1,104 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/lib/pq" + "github.com/openctemio/api/pkg/domain/shared" +) + +// TelemetryProbeRepository reads runtime_telemetry_events on behalf of +// the Stage-4 detection correlator. It implements +// validation.TelemetryProbe. +// +// Every query is tenant-scoped. Counts are capped with LIMIT-style +// short-circuits (EXISTS / LIMIT 1) where the caller only needs +// presence, so a busy tenant's telemetry volume never turns a +// validation completion into a table scan. +type TelemetryProbeRepository struct { + db *DB +} + +// NewTelemetryProbeRepository creates the repository. +func NewTelemetryProbeRepository(db *DB) *TelemetryProbeRepository { + return &TelemetryProbeRepository{db: db} +} + +// PipelineLive reports whether ANY runtime telemetry has arrived for the +// tenant since `since`. +// +// This is the guard that keeps "no telemetry integration connected" +// from being reported as "your controls detected nothing". It +// deliberately ignores asset, event type and correlation — the only +// question is whether the pipeline is delivering at all. +// +// received_at (not observed_at) is the right column: we are asking when +// data reached US, not when it happened on the endpoint. A backfill of +// week-old events still proves the pipeline is alive. +func (r *TelemetryProbeRepository) PipelineLive(ctx context.Context, tenantID shared.ID, since time.Time) (bool, error) { + const q = ` + SELECT EXISTS ( + SELECT 1 FROM runtime_telemetry_events + WHERE tenant_id = $1 AND received_at >= $2 + LIMIT 1 + ) + ` + var live bool + if err := r.db.QueryRowContext(ctx, q, tenantID.String(), since.UTC()).Scan(&live); err != nil { + return false, fmt.Errorf("telemetry pipeline liveness: %w", err) + } + return live, nil +} + +// CountByCorrelationID counts events a producer explicitly stamped with +// this validation's correlation id. No time bounds — an exact stamp is +// trustworthy whenever it arrives, which is what makes this path immune +// to the late-telemetry false negative that the time-window fallback +// suffers from. +func (r *TelemetryProbeRepository) CountByCorrelationID(ctx context.Context, tenantID, correlationID shared.ID) (int, error) { + const q = ` + SELECT COUNT(*) FROM runtime_telemetry_events + WHERE tenant_id = $1 AND correlation_id = $2 + ` + var n int + if err := r.db.QueryRowContext(ctx, q, tenantID.String(), correlationID.String()).Scan(&n); err != nil { + return 0, fmt.Errorf("count telemetry by correlation id: %w", err) + } + return n, nil +} + +// CountNearTarget counts events on the asset within [from, to] whose +// event_type is one of eventTypes. The heuristic fallback used when no +// producer stamped a correlation id. +// +// observed_at (not received_at) is the right column here: we are asking +// what happened on the endpoint during the probe window, and a +// forwarder's delivery lag must not shift the event out of the window. +func (r *TelemetryProbeRepository) CountNearTarget( + ctx context.Context, + tenantID, assetID shared.ID, + from, to time.Time, + eventTypes []string, +) (int, error) { + if len(eventTypes) == 0 { + return 0, nil + } + const q = ` + SELECT COUNT(*) FROM runtime_telemetry_events + WHERE tenant_id = $1 + AND endpoint_asset_id = $2 + AND observed_at >= $3 + AND observed_at <= $4 + AND event_type = ANY($5) + ` + var n int + err := r.db.QueryRowContext(ctx, q, + tenantID.String(), assetID.String(), from.UTC(), to.UTC(), pq.Array(eventTypes), + ).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count telemetry near target: %w", err) + } + return n, nil +} diff --git a/internal/infra/postgres/validation_evidence_repository.go b/internal/infra/postgres/validation_evidence_repository.go index e8118b7c..89410ca4 100644 --- a/internal/infra/postgres/validation_evidence_repository.go +++ b/internal/infra/postgres/validation_evidence_repository.go @@ -35,10 +35,27 @@ func (r *ValidationEvidenceRepository) Create(ctx context.Context, ev validation simRunID = sql.NullString{String: ev.SimulationRunID.String(), Valid: true} } + // Detection verdict. An empty status is stored as 'not_evaluated' + // rather than defaulted to anything that could read as a control + // failure — see validation.DetectionStatus. + detectionStatus := ev.DetectionStatus + if detectionStatus == "" { + detectionStatus = validation.DetectionNotEvaluated + } + detail := ev.DetectionDetail + if detail == nil { + detail = map[string]any{} + } + detailJSON, err := json.Marshal(detail) + if err != nil { + return fmt.Errorf("marshal detection detail: %w", err) + } + const q = ` INSERT INTO validation_evidence - (id, tenant_id, finding_id, simulation_run_id, executor_kind, technique, outcome, summary, evidence, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + (id, tenant_id, finding_id, simulation_run_id, executor_kind, technique, outcome, summary, evidence, created_at, + detection_status, detection_detail) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ` _, err = r.db.ExecContext(ctx, q, ev.ID.String(), @@ -51,6 +68,8 @@ func (r *ValidationEvidenceRepository) Create(ctx context.Context, ev validation ev.Evidence.Summary, payload, ev.CreatedAt, + string(detectionStatus), + detailJSON, ) if err != nil { return fmt.Errorf("insert validation evidence: %w", err) @@ -97,7 +116,8 @@ func (r *ValidationEvidenceRepository) CoverageBySeverity(ctx context.Context, t // to the tenant. func (r *ValidationEvidenceRepository) ListByFinding(ctx context.Context, tenantID, findingID shared.ID) ([]validation.StoredEvidence, error) { const q = ` - SELECT id, tenant_id, finding_id, simulation_run_id, evidence, created_at + SELECT id, tenant_id, finding_id, simulation_run_id, evidence, created_at, + detection_status, detection_detail FROM validation_evidence WHERE tenant_id = $1 AND finding_id = $2 ORDER BY created_at DESC @@ -114,11 +134,20 @@ func (r *ValidationEvidenceRepository) ListByFinding(ctx context.Context, tenant idStr, tenantStr, findingStr string simRunID sql.NullString payload []byte + detectionStatus string + detailJSON []byte stored validation.StoredEvidence ) - if err := rows.Scan(&idStr, &tenantStr, &findingStr, &simRunID, &payload, &stored.CreatedAt); err != nil { + if err := rows.Scan(&idStr, &tenantStr, &findingStr, &simRunID, &payload, &stored.CreatedAt, + &detectionStatus, &detailJSON); err != nil { return nil, fmt.Errorf("scan validation evidence: %w", err) } + stored.DetectionStatus = validation.DetectionStatus(detectionStatus) + if len(detailJSON) > 0 { + if err := json.Unmarshal(detailJSON, &stored.DetectionDetail); err != nil { + return nil, fmt.Errorf("unmarshal detection detail: %w", err) + } + } if stored.ID, err = shared.IDFromString(idStr); err != nil { return nil, fmt.Errorf("parse evidence id: %w", err) diff --git a/migrations/000155_runtime_telemetry.up.sql b/migrations/000155_runtime_telemetry.up.sql index a3b26bc0..73a92b2f 100644 --- a/migrations/000155_runtime_telemetry.up.sql +++ b/migrations/000155_runtime_telemetry.up.sql @@ -14,9 +14,27 @@ -- - properties JSONB holds event-specific fields. Kept intentionally -- schemaless so agents on different OSes (Windows EDR, Linux -- osquery, …) can emit without a wire-format migration every time. --- - endpoint_asset_id is nullable — during onboarding the agent may --- not yet know its asset UUID. A nightly reconciler job pairs --- events with assets by agent_id. +-- - endpoint_asset_id is nullable — during onboarding the producer may +-- not yet know its asset UUID. +-- +-- CORRECTION (2026-08-04): an earlier version of this comment promised +-- "a nightly reconciler job pairs events with assets by agent_id". +-- No such job was ever written, and it cannot be: there is no join +-- key. `agents` has no asset column, `assets` has no agent column, +-- and there is no join table — so there is nothing to pair BY. +-- +-- It is also the wrong idea. Only the producer knows which endpoint +-- an event describes. An EDR/XDR forwarder reports on MANY hosts, so +-- even the emitting agent's own hostname is not the answer. The +-- server cannot infer this after the fact. +-- +-- So a NULL here is permanent. Such an event is still stored and +-- still matched by the IOC correlator (which keys on values inside +-- the event, not on the asset), but it is invisible to every +-- asset-scoped read: Stage-4 detection correlation's heuristic +-- fallback and the per-asset Stage-6 dashboards. The ingest response +-- reports these as `unpaired` so a producer sees the degradation +-- instead of silently losing half the feature. -- - (tenant_id, observed_at) compound index — all downstream reads -- are per-tenant, time-ordered. diff --git a/migrations/000203_validation_detection_status.down.sql b/migrations/000203_validation_detection_status.down.sql new file mode 100644 index 00000000..577e248b --- /dev/null +++ b/migrations/000203_validation_detection_status.down.sql @@ -0,0 +1,15 @@ +DROP INDEX IF EXISTS idx_validation_evidence_detection; + +ALTER TABLE validation_evidence + DROP CONSTRAINT IF EXISTS chk_validation_evidence_detection_status; + +ALTER TABLE validation_evidence + DROP COLUMN IF EXISTS detection_detail; + +ALTER TABLE validation_evidence + DROP COLUMN IF EXISTS detection_status; + +DROP INDEX IF EXISTS idx_rte_correlation; + +ALTER TABLE runtime_telemetry_events + DROP COLUMN IF EXISTS correlation_id; diff --git a/migrations/000203_validation_detection_status.up.sql b/migrations/000203_validation_detection_status.up.sql new file mode 100644 index 00000000..15cc0701 --- /dev/null +++ b/migrations/000203_validation_detection_status.up.sql @@ -0,0 +1,76 @@ +-- CTEM Stage-4: "did our controls react?" +-- +-- The platform could already answer "is the exposure still reachable?" +-- (validation_evidence.outcome). It could NOT answer the other Stage-4 +-- question — whether anything in the defensive stack OBSERVED the +-- validation. This migration adds the two pieces that question needs: +-- +-- 1. runtime_telemetry_events.correlation_id — a key a telemetry +-- producer stamps so an event can be tied to the validation job +-- that provoked it. Exact correlation; no time heuristics needed. +-- +-- 2. validation_evidence.detection_status — the detection verdict, +-- in a vocabulary DELIBERATELY DISJOINT from outcome. +-- +-- Why a separate column and a separate vocabulary, not more outcome +-- values: `outcome` already uses the words 'detected' / 'not_detected' +-- to mean "the exposure was/was not still reachable". That is a +-- statement about the TARGET. The detection verdict is a statement +-- about our SENSORS. Reusing the words would make every stored row +-- ambiguous about which question it answers — and the two questions +-- have opposite polarity (outcome='detected' is bad news, +-- detection='observed' is good news). No value below appears in the +-- outcome CHECK constraint, and none of the outcome values appear +-- here, so a value can never be read against the wrong question. + +ALTER TABLE runtime_telemetry_events + ADD COLUMN IF NOT EXISTS correlation_id UUID; + +COMMENT ON COLUMN runtime_telemetry_events.correlation_id IS + 'Optional key linking this event to the validation job/command that provoked it. Stamped by the telemetry producer (EDR/XDR forwarder) and echoed from the ingest API. NULL for ordinary background telemetry.'; + +-- Partial index: correlation lookups are always tenant-scoped and only +-- ever touch stamped rows, which are a tiny minority of the stream. +CREATE INDEX IF NOT EXISTS idx_rte_correlation + ON runtime_telemetry_events (tenant_id, correlation_id) + WHERE correlation_id IS NOT NULL; + +ALTER TABLE validation_evidence + ADD COLUMN IF NOT EXISTS detection_status VARCHAR(24) NOT NULL DEFAULT 'not_evaluated'; + +ALTER TABLE validation_evidence + ADD COLUMN IF NOT EXISTS detection_detail JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- 'not_evaluated' is the default so every PRE-EXISTING row stays +-- honest: those validations ran before detection correlation existed, +-- so we genuinely do not know and must not backfill a verdict. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_validation_evidence_detection_status' + ) THEN + ALTER TABLE validation_evidence + ADD CONSTRAINT chk_validation_evidence_detection_status CHECK (detection_status IN ( + -- telemetry correlated to this validation arrived → a sensor saw it + 'observed', + -- telemetry IS flowing for this tenant, but none correlated → real detection gap + 'not_observed', + -- no telemetry reaching the platform at all → UNKNOWN, not a failure + 'no_telemetry_source', + -- the validation itself did not execute (error/skipped) → nothing to detect + 'not_applicable', + -- correlation was not wired/run for this row (incl. all historical rows) + 'not_evaluated' + )); + END IF; +END $$; + +COMMENT ON COLUMN validation_evidence.detection_status IS + 'Did any control/sensor observe this validation? Distinct question from outcome (which answers "is the exposure still reachable"). no_telemetry_source means UNKNOWN — absence of telemetry is a configuration gap, never proof a control failed.'; + +COMMENT ON COLUMN validation_evidence.detection_detail IS + 'How the detection verdict was reached: match_mode, correlation window bounds, matched event count, telemetry pipeline liveness. Lets an operator audit a verdict instead of trusting it.'; + +-- Reads are "show me validations nothing detected", tenant-scoped. +CREATE INDEX IF NOT EXISTS idx_validation_evidence_detection + ON validation_evidence (tenant_id, detection_status, created_at DESC); diff --git a/pkg/domain/audit/repository.go b/pkg/domain/audit/repository.go index 92a9c22c..57325eaf 100644 --- a/pkg/domain/audit/repository.go +++ b/pkg/domain/audit/repository.go @@ -67,6 +67,16 @@ type Repository interface { // duplicate. AppendChainEntry(ctx context.Context, entry ChainEntry) error + // GetSystemByID returns a tenant-less audit log by id. It exists so the + // chain verifier can resolve entries on the SystemChainTenantID chain, + // whose audit_logs rows have tenant_id IS NULL. + // + // Deliberately a separate method rather than relaxing GetByTenantAndID: + // that one is a tenant-isolation boundary, and widening it so a sentinel + // matches NULL rows is exactly the kind of change that later leaks a real + // tenant's rows. This one can only ever return rows with no tenant. + GetSystemByID(ctx context.Context, id shared.ID) (*AuditLog, error) + // ListChainEntries returns chain rows for verification. Ordered by // chain_position ASC. ListChainEntries(ctx context.Context, tenantID shared.ID, limit int) ([]ChainEntry, error) @@ -78,6 +88,32 @@ type Repository interface { UpdateChainEntryHashes(ctx context.Context, auditLogID shared.ID, prevHash, hash string) error } +// SystemChainTenantID is the chain that tenant-less audit events are +// appended to. +// +// The hash chain is keyed by tenant, and authentication events genuinely +// have no tenant: at login a user may belong to several tenants and has +// not chosen one yet. So they were skipped — and on the live database +// that meant 925 of 1075 audit rows (86%), including EVERY auth.login, +// auth.register and auth.failed, carried no tamper evidence at all. An +// attacker with database access could delete the record of their own +// login, or of the failed attempts that preceded it, and the chain +// verifier would report the trail intact, because it only ever walked +// rows that were chained. +// +// Nothing documented that exclusion — it was a consequence of the +// per-tenant design, not a decision. +// +// A sentinel is used rather than making audit_log_chain.tenant_id +// nullable, because that column is a tenant-isolation boundary and +// loosening it is the more dangerous change. All-Fs is deliberate: its +// version nibble is 'f', and uuid.NewV7 / uuid.New can only ever emit 7 +// or 4 there, so no generated ID can collide with it. The all-ZEROS +// UUID was rejected for the opposite reason — it is the zero value of +// shared.ID, which several call sites already test with IsZero() to mean +// "unset". +var SystemChainTenantID = shared.MustIDFromString("ffffffff-ffff-ffff-ffff-ffffffffffff") + // ChainEntry is one row of the tamper-evident audit hash-chain. // Mirrors the audit_log_chain table (migration 000154). type ChainEntry struct { diff --git a/pkg/domain/command/entity.go b/pkg/domain/command/entity.go index ec23bd80..f9c0799d 100644 --- a/pkg/domain/command/entity.go +++ b/pkg/domain/command/entity.go @@ -20,6 +20,33 @@ const ( // DefaultAuthTokenTTL is the default time-to-live for auth tokens (24 hours) DefaultAuthTokenTTL = 24 * time.Hour + + // DefaultCommandTTL is the expiry every command gets unless the caller asks + // for a different one. It is a BACKSTOP, not a scheduling knob: it exists so + // a command that nothing ever answers eventually reaches + // ExpirationChecker -> pipeline.OnStepFailed("COMMAND_EXPIRED") instead of + // leaving the owning run waiting forever. + // + // Every command used to be created with expires_at NULL, and both consumers + // of the column require `expires_at IS NOT NULL` — so FindExpired matched + // zero rows in every deployment and the checker had never expired anything. + // + // 48h is deliberately chosen to sit BEYOND every other timeout in the + // command path, so those fire first and this one only catches what they miss: + // + // 10m JobRecoveryController.TenantStuckThresholdMinutes + // 30m JobRecoveryController.StuckThresholdMinutes (platform jobs) + // 60m ExpirationChecker.MaxQueueMinutes (platform job stuck in queue) + // 1h scan.DefaultScanTimeoutSeconds -> ScanTimeoutController + // 24h scan.MaxScanTimeoutSeconds (the longest a scan may legitimately run) + // 24h DefaultAuthTokenTTL (past this a platform job cannot authenticate, + // so it can no longer be executed even if an agent picked it up) + // + // A shorter TTL would start expiring healthy in-flight work, which is worse + // than the inertness this replaces. Note also that FindExpired only matches + // status IN ('pending','acknowledged') — a command that is actually + // 'running' is never expired by this path however long it runs. + DefaultCommandTTL = 48 * time.Hour ) // CommandType represents the type of command. @@ -135,6 +162,13 @@ type Command struct { } // NewCommand creates a new Command entity. +// +// The expiry default is applied here rather than at the call sites because this +// constructor is the single seam every command creation goes through +// (scan/trigger, scan/coverage dispatch, pipeline/run, validation/dispatcher and +// the command service). Setting it per-site is what left expires_at NULL +// everywhere. Callers that need a different deadline override it afterwards with +// SetExpiration. func NewCommand(tenantID shared.ID, cmdType CommandType, priority CommandPriority, payload json.RawMessage) (*Command, error) { if cmdType == "" { return nil, shared.NewDomainError("VALIDATION", "command type is required", shared.ErrValidation) @@ -144,6 +178,9 @@ func NewCommand(tenantID shared.ID, cmdType CommandType, priority CommandPriorit priority = CommandPriorityNormal } + now := time.Now() + expiresAt := now.Add(DefaultCommandTTL) + return &Command{ ID: shared.NewID(), TenantID: tenantID, @@ -151,7 +188,8 @@ func NewCommand(tenantID shared.ID, cmdType CommandType, priority CommandPriorit Priority: priority, Payload: payload, Status: CommandStatusPending, - CreatedAt: time.Now(), + CreatedAt: now, + ExpiresAt: &expiresAt, }, nil } diff --git a/pkg/domain/command/repository.go b/pkg/domain/command/repository.go index da75c077..2a8e0537 100644 --- a/pkg/domain/command/repository.go +++ b/pkg/domain/command/repository.go @@ -48,10 +48,14 @@ type Repository interface { // Delete deletes a command. Delete(ctx context.Context, id shared.ID) error - // ExpireOldCommands expires commands that have passed their expiration time. - ExpireOldCommands(ctx context.Context) (int64, error) - // FindExpired finds commands that have expired but not yet marked as expired. + // This is the ONLY expiry path. A second reaper (ExpireOldCommands, a raw + // `UPDATE commands SET status='expired'`) used to sit alongside it and won + // the race often enough that FindExpired no longer matched the row — so the + // command died and the owning pipeline run was never told. It was removed + // from JobRecoveryController for that reason and has been deleted outright; + // do not reintroduce an expiry that does not go through ExpirationChecker, + // which calls pipeline.OnStepFailed. FindExpired(ctx context.Context) ([]*Command, error) // ========================================================================== diff --git a/pkg/domain/integration/notification_extension.go b/pkg/domain/integration/notification_extension.go index 0957bb61..880bf8ce 100644 --- a/pkg/domain/integration/notification_extension.go +++ b/pkg/domain/integration/notification_extension.go @@ -253,6 +253,39 @@ func DefaultEnabledEventTypes() []EventType { } } +// SeverityFilterApplies reports whether the per-integration severity filter is +// meaningful for this event type. +// +// EnqueueParams.Severity carries two different things depending on the event: +// +// - For finding-shaped events (new_finding, sla_breach, ...) it IS the +// finding's severity. An operator who leaves the filter at its default is +// saying "only tell me about critical and high findings", and honoring +// that is the whole point of the filter. +// +// - For approval lifecycle events it is a hardcoded constant chosen by the +// enqueue site ("medium" for requested/rejected, "low" for approved). It +// describes nothing about a finding, and no operator ever asked to +// suppress it. +// +// Running the second kind through a filter built for the first kind silently +// defeats it. That is not hypothetical: DefaultEnabledEventTypes deliberately +// includes EventTypeApprovalRequested, with the comment "if it reaches nobody +// the finding stays blocked indefinitely" — and then the severity gate dropped +// it anyway, because "medium" is not in the default critical+high set. The +// event-type gate was opened on purpose and the severity gate closed it again. +// +// Events exempted here remain fully controllable through the event-type filter, +// which is the switch that actually means "I do not want these". +func SeverityFilterApplies(eventType EventType) bool { + switch MapLegacyEventType(eventType) { + case EventTypeApprovalRequested, EventTypeApprovalApproved, EventTypeApprovalRejected: + return false + default: + return true + } +} + // AllKnownEventTypes returns all known event types (for backward compatibility API). func AllKnownEventTypes() []EventType { types := make([]EventType, 0, len(AllEventTypes())) diff --git a/pkg/domain/integration/severity_filter_test.go b/pkg/domain/integration/severity_filter_test.go new file mode 100644 index 00000000..5cb56e3c --- /dev/null +++ b/pkg/domain/integration/severity_filter_test.go @@ -0,0 +1,145 @@ +package integration + +import "testing" + +// The severity filter was silently defeating the event-type filter. +// +// DefaultEnabledEventTypes deliberately turns EventTypeApprovalRequested ON, +// with the reasoning recorded next to it: "An approval request is addressed to +// a human; if it reaches nobody the finding stays blocked indefinitely." +// +// The enqueue site stamps Severity: "medium" (vulnerability_service.go), and +// IsSeverityEnabled treats an empty enabled_severities list as critical+high. +// So the event-type gate was opened on purpose and the severity gate closed it +// again — the exact outcome that comment exists to prevent. +// +// These tests pin the two halves together so the same defeat cannot reappear. + +func TestSeverityFilter_DoesNotApplyToApprovalEvents(t *testing.T) { + approvals := []EventType{ + EventTypeApprovalRequested, + EventTypeApprovalApproved, + EventTypeApprovalRejected, + } + + for _, et := range approvals { + t.Run(string(et), func(t *testing.T) { + if SeverityFilterApplies(et) { + t.Fatalf("%s is severity-filtered, but its Severity is a constant "+ + "chosen by the enqueue site, not a finding severity", et) + } + }) + } +} + +// The filter must keep working for the events it was built for — a fix that +// delivers everything is not a fix. +func TestSeverityFilter_StillAppliesToFindingEvents(t *testing.T) { + findingShaped := []EventType{ + EventTypeNewFinding, + EventTypeNewExposure, + EventTypeFindingAssigned, + EventTypeFindingPriorityEscalated, + EventTypeSLABreach, + } + + for _, et := range findingShaped { + t.Run(string(et), func(t *testing.T) { + if !SeverityFilterApplies(et) { + t.Fatalf("%s stopped being severity-filtered: an operator who asked "+ + "for critical+high only would start receiving everything", et) + } + }) + } +} + +// The end-to-end statement of the bug: approval_requested is default-ON at the +// event-type gate, and must survive the severity gate on a default (empty) +// configuration. +func TestApprovalRequested_SurvivesBothGatesOnDefaults(t *testing.T) { + ext := &NotificationExtension{} // empty config = platform defaults + + var defaultOn bool + for _, et := range DefaultEnabledEventTypes() { + if et == EventTypeApprovalRequested { + defaultOn = true + break + } + } + if !defaultOn { + t.Fatal("EventTypeApprovalRequested is no longer default-on; if that was " + + "deliberate, this test should be deleted along with the reasoning " + + "recorded in DefaultEnabledEventTypes") + } + + if !ext.ShouldNotifyEventType(EventTypeApprovalRequested) { + t.Fatal("blocked by the event-type gate") + } + + // This is the half that was broken: "medium" is not in the default + // critical+high set, so the severity gate dropped it. + const enqueuedSeverity = "medium" // vulnerability_service.go RequestApproval + if ext.ShouldNotify(enqueuedSeverity) { + t.Fatal("test is not exercising the bug: the default severity set now " + + "includes medium, so this would pass without the fix") + } + if SeverityFilterApplies(EventTypeApprovalRequested) { + t.Fatal("approval_requested is still severity-filtered, so it is still " + + "dropped on a default configuration and the finding stays blocked") + } +} + +// Completeness gate. A new event type must be a deliberate decision about +// whether its Severity is a real severity, not a default inherited by whoever +// adds the constant. This fails the build on any unclassified addition. +func TestSeverityFilter_EveryEventTypeIsClassified(t *testing.T) { + // Severity is a constant at the enqueue site, so filtering it is meaningless. + notFilterable := map[EventType]bool{ + EventTypeApprovalRequested: true, + EventTypeApprovalApproved: true, + EventTypeApprovalRejected: true, + } + + // Severity describes a finding/exposure, so the operator's filter is real. + filterable := map[EventType]bool{ + EventTypeSecurityAlert: true, + EventTypeSystemError: true, + EventTypeNewAsset: true, + EventTypeAssetChanged: true, + EventTypeAssetDeleted: true, + EventTypeScanStarted: true, + EventTypeScanCompleted: true, + EventTypeScanFailed: true, + EventTypeNewFinding: true, + EventTypeFindingConfirmed: true, + EventTypeFindingTriaged: true, + EventTypeFindingFixed: true, + EventTypeFindingReopened: true, + EventTypeFindingPriorityEscalated: true, + EventTypeFindingAssigned: true, + EventTypeSLABreach: true, + EventTypeWorkflowNotification: true, + EventTypeNewExposure: true, + EventTypeExposureResolved: true, + } + + for _, info := range AllEventTypes() { + et := info.Type + switch { + case notFilterable[et]: + if SeverityFilterApplies(et) { + t.Errorf("%s is listed as not-filterable here but SeverityFilterApplies says otherwise", et) + } + case filterable[et]: + if !SeverityFilterApplies(et) { + t.Errorf("%s is listed as filterable here but SeverityFilterApplies says otherwise", et) + } + default: + t.Errorf("event type %q is not classified. Decide whether its "+ + "EnqueueParams.Severity is a real finding severity (add it to "+ + "`filterable`) or a constant picked by the enqueue site (add it to "+ + "`notFilterable` AND to SeverityFilterApplies). Inheriting the "+ + "default silently is how approval_requested became undeliverable.", et) + } + } +} diff --git a/scripts/check-openapi.sh b/scripts/check-openapi.sh new file mode 100755 index 00000000..bde05bc6 --- /dev/null +++ b/scripts/check-openapi.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# +# check-openapi.sh — the OpenAPI contract gate. +# +# WHY: api/openapi/swagger.yaml is generated by swag from the handler +# // @Router annotations, and nothing ever verified that the committed file +# still matched them. It drifted far enough to describe a different server: +# +# • 30 documented paths had no handler and no route anywhere in the repo +# (/admin/platform-agents, /plans, /tenants/{id}/subscription, ...), +# leftovers from a closed-source era. +# • 40 real endpoints were undocumented, the whole /notifications API among +# them, plus GET /auth/providers. +# +# That is a client bug, not a docs bug. The UI generates its API types from this +# file, so a stale entry ships a request to an endpoint that does not exist — +# which is exactly how the UI came to call GET /api/v1/me/event-types against a +# server that never had it — and a missing entry hides a feature from every +# client. +# +# WHY NOT "REGENERATE AND DIFF" +# +# The obvious gate is a byte comparison against a fresh regeneration. It was +# tried, on this branch, and it does not hold: swag is not hermetic across +# environments. A clean CI runner drops `format: int64` from some +# map[string]int64 fields that a developer machine emits, with the same pinned +# swag v1.16.4, the same Go 1.26.5 and a freshly downloaded module cache. Both +# documents say the same thing about the same Go types, so the gate would fail +# on a difference that describes no disagreement, that the developer who tripped +# it cannot fix, and whose only available resolution is to weaken or delete the +# gate. That is worse than no gate. +# +# So this gates the PROPERTY, not the byte representation — see +# tools/lint/openapicontract for the three set comparisons: +# +# A. every @Router annotation is in the spec, and every spec path is an +# annotation (the spec is generated; you cannot hand-edit it) +# B. every documented path+method has a registered route (no phantoms) +# C. every registered route is documented or listed in +# api/openapi/undocumented-routes.txt (the debt is frozen, not growing) +# +# Set comparison is stable across swag's formatting quirks and is the thing a +# generated client actually depends on. +# +# NOTE: the spec is generated-but-not-byte-gated. Cosmetic churn in swag's +# output — a format keyword, a description reflow — will not fail this check. +# What cannot drift is the set of operations. +# +# Usage: +# scripts/check-openapi.sh +# +# To fix a failure: follow the message. Usually `make swagger` and commit. +# +# Exit codes: 0 = contract holds, 1 = drift, 2 = tooling error. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +if [ ! -f "$REPO_ROOT/api/openapi/swagger.yaml" ]; then + echo "check-openapi: api/openapi/swagger.yaml is missing." >&2 + exit 2 +fi +if [ ! -f "$REPO_ROOT/api/openapi/undocumented-routes.txt" ]; then + echo "check-openapi: api/openapi/undocumented-routes.txt is missing." >&2 + echo " Without the baseline the debt check cannot run, and a" >&2 + echo " check that cannot run must not report as passed." >&2 + exit 2 +fi + +cd "$REPO_ROOT" + +if GOWORK=off go test ./tools/lint/openapicontract/... -count=1; then + echo "check-openapi: annotations, spec and routes agree." + exit 0 +fi + +cat >&2 <<'EOF' + +──────────────────────────────────────────────────────────────────────────── +OpenAPI contract check failed. The message above names the operations. + + • "in the committed spec with no @Router annotation" + swagger.yaml is GENERATED. Run `make swagger` and commit the result. + Never hand-edit it. + + • "annotated but absent from the committed spec" + You changed a handler's @Router without regenerating. + Run `make swagger` and commit the result. + + • "have no registered route" + The spec advertises an endpoint that 404s. Either register the route, + or correct the handler's @Router to the path it is really served on + and rerun `make swagger`. + + • "neither documented nor baselined" + A new route no client can discover. Add a // @Router annotation and + run `make swagger` — or, if documenting it now is genuinely out of + scope, add the line to api/openapi/undocumented-routes.txt so the + choice is visible in review. +──────────────────────────────────────────────────────────────────────────── +EOF +exit 1 diff --git a/scripts/release-branch.sh b/scripts/release-branch.sh new file mode 100755 index 00000000..7e14466d --- /dev/null +++ b/scripts/release-branch.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# release-branch.sh — build a release branch that can actually merge into main. +# +# WHY THIS EXISTS: releases here are squash-merged, so main's HEAD ends up with a +# single parent and git cannot see that develop already contains it. The next +# release then reports dozens of conflicting files that are not disagreements at +# all — 27 of them the first time this was hit, in files neither side had +# meaningfully diverged on. +# +# Merging main back into develop is the textbook answer and it does not stick: +# the following release squashes again and the ancestry breaks again. It has +# broken four times. +# +# So this stops fighting the merge strategy. The branch carries its own +# ancestry: develop's tree byte-for-byte, plus one `merge -s ours` commit that +# records main as a parent. It merges cleanly no matter which button anyone +# presses, and it costs one command instead of one careful afternoon. +# +# Usage: +# scripts/release-branch.sh v0.5.0 +# scripts/release-branch.sh v0.5.0 --push +set -euo pipefail + +VERSION="${1:-}" +PUSH="${2:-}" + +if [[ -z "$VERSION" ]]; then + echo "usage: $0 [--push] e.g. $0 v0.5.0" >&2 + exit 2 +fi +if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "error: version should look like v1.2.3, got '$VERSION'" >&2 + exit 2 +fi + +BRANCH="release/${VERSION}" +git fetch --quiet origin '+refs/heads/*:refs/remotes/origin/*' + +# ── The precondition that makes `-s ours` honest ──────────────────────────── +# +# `-s ours` discards main's side entirely. That is only safe if develop already +# contains everything main has. Check it rather than assume it: if someone +# hotfixed main directly, this is the one thing standing between that fix and +# oblivion. +only_in_main="$(git diff --name-status origin/develop origin/main | awk '$1=="A"' | wc -l)" +if [[ "$only_in_main" -ne 0 ]]; then + echo "REFUSING: $only_in_main file(s) exist in main but not in develop." >&2 + echo "-s ours would silently drop them. Merge main into develop first:" >&2 + git diff --name-status origin/develop origin/main | awk '$1=="A"{print " "$2}' >&2 + exit 1 +fi + +if git rev-parse --verify --quiet "origin/${BRANCH}" >/dev/null; then + echo "REFUSING: origin/${BRANCH} already exists." >&2 + echo "Delete it, or pick another version — this script will not force-push." >&2 + exit 1 +fi + +git branch -f "$BRANCH" origin/develop +worktree="$(mktemp -d)" +trap 'git worktree remove --force "$worktree" >/dev/null 2>&1 || true' EXIT +git worktree add --quiet "$worktree" "$BRANCH" + +git -C "$worktree" merge -s ours --no-edit origin/main -m "chore(release): carry main's ancestry into ${VERSION} + +The previous release was squash-merged, so main's HEAD has a single parent and +git cannot see that develop already contains it. Without this commit, merging +develop into main reports conflicts that are not real disagreements. + +-s ours keeps develop's tree byte-for-byte and only records main as a parent. +Generated by scripts/release-branch.sh." >/dev/null + +# ── Verify, don't assert ──────────────────────────────────────────────────── +tree_branch="$(git -C "$worktree" rev-parse 'HEAD^{tree}')" +tree_develop="$(git rev-parse 'origin/develop^{tree}')" +if [[ "$tree_branch" != "$tree_develop" ]]; then + echo "REFUSING: the release branch tree differs from develop. Not pushing." >&2 + exit 1 +fi +if ! git -C "$worktree" merge-base --is-ancestor origin/main HEAD; then + echo "REFUSING: main is still not an ancestor. The merge did not take." >&2 + exit 1 +fi + +echo "${BRANCH} built:" +echo " tree identical to develop : yes" +echo " main recorded as an ancestor: yes" +echo " files in main absent from develop: 0" + +if [[ "$PUSH" == "--push" ]]; then + git push --quiet -u origin "$BRANCH" + echo " pushed" + echo + echo "Next: open a PR from ${BRANCH} into main, merge it, then tag ${VERSION} on main." + echo "Prefer a merge commit — a squash leaves main single-parent and the next" + echo "release needs this script again." +else + echo + echo "Not pushed. Re-run with --push when you are ready." +fi diff --git a/tests/integration/command_recovery_test.go b/tests/integration/command_recovery_test.go index 4ad5816a..cb0b31dc 100644 --- a/tests/integration/command_recovery_test.go +++ b/tests/integration/command_recovery_test.go @@ -1,6 +1,7 @@ package integration import ( + "context" "database/sql" "fmt" "os" @@ -19,6 +20,7 @@ import ( func TestRecoverStuckTenantCommands(t *testing.T) { db := setupCommandTestDB(t) defer db.Close() + defer lockGlobalSweep(context.Background(), t, db)() tenantID := createTestTenantForCommand(t, db) agentID := createTestAgent(t, db, tenantID, "online") @@ -159,6 +161,7 @@ func TestRecoverStuckTenantCommands(t *testing.T) { func TestFailExhaustedCommands(t *testing.T) { db := setupCommandTestDB(t) defer db.Close() + defer lockGlobalSweep(context.Background(), t, db)() tenantID := createTestTenantForCommand(t, db) offlineAgentID := createTestAgent(t, db, tenantID, "offline") @@ -287,6 +290,7 @@ func TestFailExhaustedCommands(t *testing.T) { func TestRecoveryAndFailIntegration(t *testing.T) { db := setupCommandTestDB(t) defer db.Close() + defer lockGlobalSweep(context.Background(), t, db)() tenantID := createTestTenantForCommand(t, db) offlineAgentID := createTestAgent(t, db, tenantID, "offline") @@ -362,9 +366,14 @@ func TestRecoveryAndFailIntegration(t *testing.T) { func setupCommandTestDB(t *testing.T) *sql.DB { t.Helper() + // No default. These tests write rows and run cross-tenant sweeps; a default + // of "openctem" pointed an unconfigured `go test ./...` at whatever database + // that name resolves to on the machine running it - in a dev environment, the + // real one. Skipping is the only safe unset behavior, and it matches + // openPlatformJobDB in internal/infra/postgres. dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { - dbURL = "postgres://openctem@localhost:5432/openctem?sslmode=disable" + t.Skip("DATABASE_URL not set; skipping command recovery tests") } db, err := sql.Open("postgres", dbURL) diff --git a/tests/integration/global_sweep_lock_test.go b/tests/integration/global_sweep_lock_test.go new file mode 100644 index 00000000..1a06f566 --- /dev/null +++ b/tests/integration/global_sweep_lock_test.go @@ -0,0 +1,80 @@ +package integration + +import ( + "context" + "database/sql" + "testing" +) + +// Several repository sweeps are deliberately GLOBAL — RecoverStuckJobs, +// ExpireOldPlatformJobs, MarkStaleAsOffline and the recover_stuck_* SQL +// functions all operate across every tenant, because that is what a background +// reaper does. +// +// That makes them untestable in parallel against a shared database, and two +// packages share one: internal/infra/postgres and tests/integration. `go test +// ./...` runs their binaries concurrently. +// +// The collision is mechanically certain but has NOT been observed failing. +// platform_job_lifecycle_db_test seeds a non-platform command, acknowledged, 120 +// minutes old, with a tenant agent, and asserts the PLATFORM sweep leaves it +// alone. `recover_stuck_tenant_commands(10, 3)`, called from +// tests/integration/command_recovery_test, selects on exactly those columns: +// is_platform_job = FALSE, status = 'acknowledged', agent_id IS NOT NULL, +// acknowledged_at older than the threshold, dispatch_attempts under the cap. Run +// against that row by hand it returns 1 and rewrites it to pending/1 — precisely +// what the assertion forbids. +// +// What has not happened is the two landing together by chance: 120 concurrent +// rounds of both packages produced no failure, because each test seeds, sweeps +// and asserts inside ~20-80ms. So this is a latent hazard, not a flake anyone is +// currently suffering. It is worth closing anyway — the window widens with -race +// (which CI uses), with a loaded runner, and with every test added to either +// package — but it should not be sold as a fix for observed CI noise. +// +// A serializing lock is the honest fix. Weakening the assertions would remove +// the thing they exist to catch, and `-p 1` would serialize 91 packages to +// discipline two. +// +// The same helper exists in internal/infra/postgres. Keeping a copy rather than +// introducing a shared testutil package is deliberate: it is nine lines, and the +// lock key is the contract between them — that must be identical, and it is +// easier to see that when both files state it. +const globalSweepLockKey = 8_845_120_301 // arbitrary, must match internal/infra/postgres + +// lockGlobalSweep serializes a test that runs a cross-tenant sweep against every +// other such test, in this package and in tests/integration. Call it once at the +// top of the test and defer the returned release: +// +// defer lockGlobalSweep(ctx, t, db)() +// +// It must wrap the WHOLE test, not just the sweep call. The race is between one +// package's seed and its assertion: a sweep from the other package landing in +// that window recovers the row out from under it. Locking only the sweep call +// would leave exactly that window open. +func lockGlobalSweep(ctx context.Context, t *testing.T, db *sql.DB) func() { + t.Helper() + + conn, err := db.Conn(ctx) + if err != nil { + t.Fatalf("global sweep lock: acquire connection: %v", err) + } + + // Session-level, not transaction-level: the sweeps under test run their own + // statements on other pool connections, so the lock must outlive any single + // transaction. + if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", globalSweepLockKey); err != nil { + _ = conn.Close() + t.Fatalf("global sweep lock: %v", err) + } + + return func() { + // context.Background(): the test's context may already be canceled, and + // failing to unlock would block every later sweep test in the run. + if _, err := conn.ExecContext(context.Background(), + "SELECT pg_advisory_unlock($1)", globalSweepLockKey); err != nil { + t.Errorf("global sweep unlock: %v — later tests in this run may block", err) + } + _ = conn.Close() + } +} diff --git a/tests/integration/rls_tenant_isolation_test.go b/tests/integration/rls_tenant_isolation_test.go index 21c86602..3441699d 100644 --- a/tests/integration/rls_tenant_isolation_test.go +++ b/tests/integration/rls_tenant_isolation_test.go @@ -310,8 +310,7 @@ func setupTestDB(t *testing.T) *sql.DB { dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { - // Try common local development configurations - dbURL = "postgres://openctem@localhost:5432/openctem?sslmode=disable" + t.Skip("DATABASE_URL not set; skipping RLS isolation tests") } db, err := sql.Open("postgres", dbURL) @@ -335,7 +334,7 @@ func setupRLSTestDB(t *testing.T) *sql.DB { // Connect as non-superuser for RLS testing dbURL := os.Getenv("DATABASE_URL_RLS_TEST") if dbURL == "" { - dbURL = "postgres://rls_test_user:test_password_123@localhost:5432/openctem?sslmode=disable" + t.Skip("DATABASE_URL_RLS_TEST not set; skipping RLS test-user tests") } db, err := sql.Open("postgres", dbURL) diff --git a/tests/integration/validation_detection_test.go b/tests/integration/validation_detection_test.go new file mode 100644 index 00000000..ba3a4b6f --- /dev/null +++ b/tests/integration/validation_detection_test.go @@ -0,0 +1,221 @@ +package integration + +import ( + "context" + "testing" + "time" + + "github.com/openctemio/api/internal/app/validation" + "github.com/openctemio/api/internal/infra/postgres" + "github.com/openctemio/api/pkg/domain/shared" +) + +// Stage-4 detection correlation against real SQL. +// +// These exercise internal/infra/postgres/telemetry_probe_repository.go — +// the queries the DetectionCorrelator depends on. Unit tests fake the +// probe, so without these the SQL itself (correlation_id cast, event_type +// = ANY($5), the observed_at vs received_at split) is unverified. + +// TestDetectionCorrelation_NoTelemetryPipeline is the field case: a tenant +// with zero telemetry rows must come back no_telemetry_source, NOT +// not_observed. Today no first-party producer writes this table at all, +// so this is the state every real tenant is in. +func TestDetectionCorrelation_NoTelemetryPipeline(t *testing.T) { + sqlDB := setupTestDB(t) + db := &postgres.DB{DB: sqlDB} + + tenantID := createTestTenant(t, sqlDB, "detnone") + assetID := createTestAsset(t, sqlDB, tenantID, "detnone-asset") + t.Cleanup(func() { cleanupTestData(sqlDB, tenantID) }) + + c := validation.NewDetectionCorrelator(postgres.NewTelemetryProbeRepository(db)) + start := time.Now().UTC().Add(-time.Minute) + + v := c.Evaluate(context.Background(), tenantID, validation.Evidence{ + ExecutorKind: "safe-check", + Outcome: validation.OutcomeDetected, + StartedAt: start, + EndedAt: start.Add(5 * time.Second), + Target: validation.Target{AssetID: assetID, Type: "host", Address: "h:443"}, + }, shared.ID{}) + + if v.Status != validation.DetectionNoTelemetrySource { + t.Fatalf("status = %q, want %q", v.Status, validation.DetectionNoTelemetrySource) + } + if v.Status.IsDetectionGap() { + t.Fatal("a tenant with no telemetry integration must not be reported as a failed control") + } +} + +// TestDetectionCorrelation_ObservedByCorrelationID proves the exact-match +// SQL path end to end, including the UUID column. +func TestDetectionCorrelation_ObservedByCorrelationID(t *testing.T) { + sqlDB := setupTestDB(t) + db := &postgres.DB{DB: sqlDB} + + tenantID := createTestTenant(t, sqlDB, "detcorr") + assetID := createTestAsset(t, sqlDB, tenantID, "detcorr-asset") + t.Cleanup(func() { cleanupTestData(sqlDB, tenantID) }) + + corrID := shared.NewID() + start := time.Now().UTC().Add(-time.Minute) + + // A control reacted and its forwarder stamped the correlation id. + // observed_at is deliberately OUTSIDE the 5-minute window to prove the + // stamped path is time-independent. + if _, err := sqlDB.Exec(` + INSERT INTO runtime_telemetry_events + (tenant_id, endpoint_asset_id, event_type, severity, observed_at, properties, correlation_id) + VALUES ($1, $2, 'network_connect', 'medium', $3, '{}'::jsonb, $4)`, + tenantID.String(), assetID.String(), start.Add(-6*time.Hour), corrID.String(), + ); err != nil { + t.Fatalf("insert telemetry: %v", err) + } + + c := validation.NewDetectionCorrelator(postgres.NewTelemetryProbeRepository(db)) + v := c.Evaluate(context.Background(), tenantID, validation.Evidence{ + ExecutorKind: "safe-check", + Outcome: validation.OutcomeDetected, + StartedAt: start, + EndedAt: start.Add(5 * time.Second), + Target: validation.Target{AssetID: assetID, Type: "host", Address: "h:443"}, + }, corrID) + + if v.Status != validation.DetectionObserved { + t.Fatalf("status = %q, want %q", v.Status, validation.DetectionObserved) + } + if v.Detail["match_mode"] != "correlation_id" { + t.Fatalf("match_mode = %v, want correlation_id", v.Detail["match_mode"]) + } +} + +// TestDetectionCorrelation_HeuristicWindowAndTypeFilter proves the +// fallback SQL: telemetry is flowing, but only an in-window event of a +// plausible type counts. +func TestDetectionCorrelation_HeuristicWindowAndTypeFilter(t *testing.T) { + sqlDB := setupTestDB(t) + db := &postgres.DB{DB: sqlDB} + + tenantID := createTestTenant(t, sqlDB, "detheur") + assetID := createTestAsset(t, sqlDB, tenantID, "detheur-asset") + t.Cleanup(func() { cleanupTestData(sqlDB, tenantID) }) + + start := time.Now().UTC().Add(-time.Minute) + ev := validation.Evidence{ + ExecutorKind: "safe-check", + Outcome: validation.OutcomeDetected, + StartedAt: start, + EndedAt: start.Add(5 * time.Second), + Target: validation.Target{AssetID: assetID, Type: "host", Address: "h:443"}, + } + probe := postgres.NewTelemetryProbeRepository(db) + c := validation.NewDetectionCorrelator(probe) + + // 1. In-window but an IMPLAUSIBLE type for a remote network probe. + // The pipeline is now live, so the verdict must be not_observed — + // a real gap — and must NOT be dragged to "observed" by host noise. + if _, err := sqlDB.Exec(` + INSERT INTO runtime_telemetry_events + (tenant_id, endpoint_asset_id, event_type, severity, observed_at, properties) + VALUES ($1, $2, 'file_write', 'info', $3, '{}'::jsonb)`, + tenantID.String(), assetID.String(), start.Add(time.Second), + ); err != nil { + t.Fatalf("insert noise telemetry: %v", err) + } + + v := c.Evaluate(context.Background(), tenantID, ev, shared.ID{}) + if v.Status != validation.DetectionNotObserved { + t.Fatalf("with only implausible-type noise: status = %q, want %q (detail=%v)", + v.Status, validation.DetectionNotObserved, v.Detail) + } + if live, _ := v.Detail["telemetry_pipeline_live"].(bool); !live { + t.Fatal("pipeline must be reported live once any row exists") + } + + // 2. A plausible type inside the window → observed. + if _, err := sqlDB.Exec(` + INSERT INTO runtime_telemetry_events + (tenant_id, endpoint_asset_id, event_type, severity, observed_at, properties) + VALUES ($1, $2, 'network_connect', 'medium', $3, '{}'::jsonb)`, + tenantID.String(), assetID.String(), start.Add(2*time.Second), + ); err != nil { + t.Fatalf("insert matching telemetry: %v", err) + } + + v = c.Evaluate(context.Background(), tenantID, ev, shared.ID{}) + if v.Status != validation.DetectionObserved { + t.Fatalf("status = %q, want %q (detail=%v)", v.Status, validation.DetectionObserved, v.Detail) + } + if v.Detail["confidence"] != "heuristic" { + t.Fatalf("confidence = %v, want heuristic", v.Detail["confidence"]) + } + + // 3. A plausible type OUTSIDE the window must not match. Push the + // evidence window back so the row above falls beyond post-window. + late := ev + late.StartedAt = start.Add(-2 * time.Hour) + late.EndedAt = late.StartedAt.Add(5 * time.Second) + v = c.Evaluate(context.Background(), tenantID, late, shared.ID{}) + if v.Status != validation.DetectionNotObserved { + t.Fatalf("out-of-window telemetry must not match: status = %q, want %q", + v.Status, validation.DetectionNotObserved) + } +} + +// TestDetectionCorrelation_PersistedOnEvidence proves the verdict reaches +// the validation_evidence row (real columns + CHECK constraint). +func TestDetectionCorrelation_PersistedOnEvidence(t *testing.T) { + sqlDB := setupTestDB(t) + db := &postgres.DB{DB: sqlDB} + + tenantID := createTestTenant(t, sqlDB, "detpersist") + assetID := createTestAsset(t, sqlDB, tenantID, "detpersist-asset") + findingID := createTestFinding(t, sqlDB, tenantID, assetID, "detection persist") + t.Cleanup(func() { cleanupTestData(sqlDB, tenantID) }) + + store := validation.NewEvidenceStore(postgres.NewValidationEvidenceRepository(db)) + store.SetDetectionCorrelator( + validation.NewDetectionCorrelator(postgres.NewTelemetryProbeRepository(db)), + ) + + start := time.Now().UTC().Add(-time.Minute) + if _, err := store.Record(context.Background(), tenantID, findingID, nil, validation.Evidence{ + ExecutorKind: "safe-check", + Technique: "T1046", + Target: validation.Target{AssetID: assetID, Type: "host", Address: "h:443"}, + StartedAt: start, + EndedAt: start.Add(5 * time.Second), + Outcome: validation.OutcomeDetected, + Summary: "still reachable", + }); err != nil { + t.Fatalf("record: %v", err) + } + + var status string + if err := sqlDB.QueryRow( + `SELECT detection_status FROM validation_evidence WHERE tenant_id = $1 AND finding_id = $2`, + tenantID.String(), findingID.String(), + ).Scan(&status); err != nil { + t.Fatalf("read back detection_status: %v", err) + } + if status != string(validation.DetectionNoTelemetrySource) { + t.Fatalf("persisted detection_status = %q, want %q", status, validation.DetectionNoTelemetrySource) + } + + // And it must survive the read path. + rows, err := postgres.NewValidationEvidenceRepository(db).ListByFinding(context.Background(), tenantID, findingID) + if err != nil { + t.Fatalf("ListByFinding: %v", err) + } + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1", len(rows)) + } + if rows[0].DetectionStatus != validation.DetectionNoTelemetrySource { + t.Fatalf("round-tripped status = %q, want %q", + rows[0].DetectionStatus, validation.DetectionNoTelemetrySource) + } + if rows[0].DetectionDetail["reason"] == nil { + t.Fatal("detection_detail must round-trip the reason") + } +} diff --git a/tests/unit/audit_service_test.go b/tests/unit/audit_service_test.go index 1524af95..beeb35a9 100644 --- a/tests/unit/audit_service_test.go +++ b/tests/unit/audit_service_test.go @@ -126,6 +126,10 @@ func (m *mockAuditRepo) GetByTenantAndID(_ context.Context, _, id shared.ID) (*a return log, nil } +func (m *mockAuditRepo) GetSystemByID(_ context.Context, id shared.ID) (*audit.AuditLog, error) { + return nil, audit.AuditLogNotFoundError(id) +} + func (m *mockAuditRepo) List(_ context.Context, filter audit.Filter, page pagination.Pagination) (pagination.Result[*audit.AuditLog], error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/tests/unit/auth_service_test.go b/tests/unit/auth_service_test.go index 06da216c..4d45e366 100644 --- a/tests/unit/auth_service_test.go +++ b/tests/unit/auth_service_test.go @@ -714,6 +714,10 @@ func (m *mockAuthAuditRepo) GetByTenantAndID(_ context.Context, _, _ shared.ID) return nil, nil } +func (m *mockAuthAuditRepo) GetSystemByID(_ context.Context, id shared.ID) (*audit.AuditLog, error) { + return nil, audit.AuditLogNotFoundError(id) +} + func (m *mockAuthAuditRepo) List(_ context.Context, _ audit.Filter, _ pagination.Pagination) (pagination.Result[*audit.AuditLog], error) { return pagination.Result[*audit.AuditLog]{}, nil } diff --git a/tests/unit/command_service_test.go b/tests/unit/command_service_test.go index 3e0cd1c1..51152e74 100644 --- a/tests/unit/command_service_test.go +++ b/tests/unit/command_service_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "testing" + "time" "github.com/openctemio/api/internal/app/command" @@ -29,8 +30,6 @@ type cmdMockRepo struct { deleteErr error listErr error getPendingErr error - expireErr error - expireCount int64 findExpiredResult []*commanddom.Command findExpiredErr error getByAuthTokenHashErr error @@ -154,13 +153,6 @@ func (m *cmdMockRepo) Delete(_ context.Context, id shared.ID) error { return nil } -func (m *cmdMockRepo) ExpireOldCommands(_ context.Context) (int64, error) { - if m.expireErr != nil { - return 0, m.expireErr - } - return m.expireCount, nil -} - func (m *cmdMockRepo) FindExpired(_ context.Context) ([]*commanddom.Command, error) { if m.findExpiredErr != nil { return nil, m.findExpiredErr @@ -473,8 +465,26 @@ func TestCommandService_CreateCommand_NoExpiration(t *testing.T) { if err != nil { t.Fatalf("expected no error, got %v", err) } - if cmd.ExpiresAt != nil { - t.Error("expected no expiration, got one") + // Omitting ExpiresIn must NOT mean "never expires". This test used to assert + // ExpiresAt == nil, which is exactly the state that made + // ExpirationChecker inert: FindExpired requires `expires_at IS NOT NULL`, + // so a NULL here is a command no reaper can ever see. + assertDefaultCommandTTL(t, cmd.ExpiresAt) +} + +// assertDefaultCommandTTL checks a command carries the backstop expiry. +func assertDefaultCommandTTL(t *testing.T, expiresAt *time.Time) { + t.Helper() + + if expiresAt == nil { + t.Fatal("ExpiresAt is nil: FindExpired requires `expires_at IS NOT NULL`, " + + "so this command can never be expired and the pipeline run waiting on it " + + "will never receive COMMAND_EXPIRED") + } + ttl := time.Until(*expiresAt) + if ttl < commanddom.DefaultCommandTTL-time.Minute || ttl > commanddom.DefaultCommandTTL+time.Minute { + t.Fatalf("ExpiresAt is %v away, want ~%v (DefaultCommandTTL)", + ttl, commanddom.DefaultCommandTTL) } } @@ -1423,49 +1433,6 @@ func TestCommandService_DeleteCommand_RepoDeleteError(t *testing.T) { } } -// ============================================================================= -// Tests: ExpireOldCommands -// ============================================================================= - -func TestCommandService_ExpireOldCommands_Success(t *testing.T) { - repo := newCmdMockRepo() - repo.expireCount = 5 - svc := newCmdTestService(repo) - - count, err := svc.ExpireOldCommands(context.Background()) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if count != 5 { - t.Errorf("expected 5 expired, got %d", count) - } -} - -func TestCommandService_ExpireOldCommands_Zero(t *testing.T) { - repo := newCmdMockRepo() - repo.expireCount = 0 - svc := newCmdTestService(repo) - - count, err := svc.ExpireOldCommands(context.Background()) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if count != 0 { - t.Errorf("expected 0 expired, got %d", count) - } -} - -func TestCommandService_ExpireOldCommands_RepoError(t *testing.T) { - repo := newCmdMockRepo() - repo.expireErr = errors.New("expire failed") - svc := newCmdTestService(repo) - - _, err := svc.ExpireOldCommands(context.Background()) - if err == nil { - t.Fatal("expected error from repo") - } -} - // ============================================================================= // Tests: Full State Machine Transitions // ============================================================================= @@ -1821,9 +1788,9 @@ func TestCommandService_CreateCommand_ZeroExpiresIn(t *testing.T) { if err != nil { t.Fatalf("expected no error, got %v", err) } - if cmd.ExpiresAt != nil { - t.Error("expected no expiration for zero ExpiresIn") - } + // Zero means "no explicit deadline", which falls back to the default + // backstop — not to NULL. + assertDefaultCommandTTL(t, cmd.ExpiresAt) } func TestCommandService_CreateCommand_NegativeExpiresIn(t *testing.T) { @@ -1840,10 +1807,10 @@ func TestCommandService_CreateCommand_NegativeExpiresIn(t *testing.T) { if err != nil { t.Fatalf("expected no error, got %v", err) } - // Negative ExpiresIn is <= 0, so should not set expiration - if cmd.ExpiresAt != nil { - t.Error("expected no expiration for negative ExpiresIn") - } + // Negative ExpiresIn is <= 0, so no explicit deadline is applied and the + // default backstop stands. It must never leave expires_at NULL, and it must + // never produce an already-past deadline. + assertDefaultCommandTTL(t, cmd.ExpiresAt) } func TestCommandService_GetCommand_RepoError(t *testing.T) { diff --git a/tests/unit/module_service_test.go b/tests/unit/module_service_test.go index c7895f3b..1e160b0a 100644 --- a/tests/unit/module_service_test.go +++ b/tests/unit/module_service_test.go @@ -208,6 +208,10 @@ func (m *moduleAuditMockRepo) GetByTenantAndID(_ context.Context, _, _ shared.ID return nil, nil } +func (m *moduleAuditMockRepo) GetSystemByID(_ context.Context, id shared.ID) (*audit.AuditLog, error) { + return nil, audit.AuditLogNotFoundError(id) +} + func (m *moduleAuditMockRepo) List(_ context.Context, _ audit.Filter, _ pagination.Pagination) (pagination.Result[*audit.AuditLog], error) { return pagination.Result[*audit.AuditLog]{}, nil } diff --git a/tests/unit/rule_service_test.go b/tests/unit/rule_service_test.go index 0312440d..21cdd02b 100644 --- a/tests/unit/rule_service_test.go +++ b/tests/unit/rule_service_test.go @@ -561,10 +561,18 @@ func (m *ruleSvcMockAuditRepo) GetByID(_ context.Context, _ shared.ID) (*audit.A return nil, errors.New("not implemented") } +func (m *ruleSvcMockSourceRepo) GetSystemByID(_ context.Context, id shared.ID) (*audit.AuditLog, error) { + return nil, audit.AuditLogNotFoundError(id) +} + func (m *ruleSvcMockAuditRepo) GetByTenantAndID(_ context.Context, _, _ shared.ID) (*audit.AuditLog, error) { return nil, nil } +func (m *ruleSvcMockAuditRepo) GetSystemByID(_ context.Context, id shared.ID) (*audit.AuditLog, error) { + return nil, audit.AuditLogNotFoundError(id) +} + func (m *ruleSvcMockAuditRepo) List(_ context.Context, _ audit.Filter, _ pagination.Pagination) (pagination.Result[*audit.AuditLog], error) { return pagination.Result[*audit.AuditLog]{}, nil } diff --git a/tests/unit/scan_service_test.go b/tests/unit/scan_service_test.go index 12756df1..97daf54b 100644 --- a/tests/unit/scan_service_test.go +++ b/tests/unit/scan_service_test.go @@ -518,7 +518,6 @@ func (m *mockCommandRepo) List(_ context.Context, _ commanddom.Filter, _ paginat } func (m *mockCommandRepo) Update(_ context.Context, _ *commanddom.Command) error { return nil } func (m *mockCommandRepo) Delete(_ context.Context, _ shared.ID) error { return nil } -func (m *mockCommandRepo) ExpireOldCommands(_ context.Context) (int64, error) { return 0, nil } func (m *mockCommandRepo) FindExpired(_ context.Context) ([]*commanddom.Command, error) { return nil, nil } diff --git a/tests/unit/secretstore_service_test.go b/tests/unit/secretstore_service_test.go index df6028a2..bf62e3b7 100644 --- a/tests/unit/secretstore_service_test.go +++ b/tests/unit/secretstore_service_test.go @@ -202,10 +202,18 @@ func (m *secretMockAuditRepo) GetByID(_ context.Context, _ shared.ID) (*audit.Au return nil, errors.New("not implemented") } +func (m *secretMockRepo) GetSystemByID(_ context.Context, id shared.ID) (*audit.AuditLog, error) { + return nil, audit.AuditLogNotFoundError(id) +} + func (m *secretMockAuditRepo) GetByTenantAndID(_ context.Context, _, _ shared.ID) (*audit.AuditLog, error) { return nil, nil } +func (m *secretMockAuditRepo) GetSystemByID(_ context.Context, id shared.ID) (*audit.AuditLog, error) { + return nil, audit.AuditLogNotFoundError(id) +} + func (m *secretMockAuditRepo) List(_ context.Context, _ audit.Filter, _ pagination.Pagination) (pagination.Result[*audit.AuditLog], error) { return pagination.Result[*audit.AuditLog]{}, nil } diff --git a/tools/lint/openapicontract/contract.go b/tools/lint/openapicontract/contract.go new file mode 100644 index 00000000..5c1c00de --- /dev/null +++ b/tools/lint/openapicontract/contract.go @@ -0,0 +1,309 @@ +// Package openapicontract checks that three descriptions of this server's HTTP +// surface agree: the // @Router annotations on the handlers, the committed +// OpenAPI spec, and the routes actually registered with the router. +// +// # WHY NOT A BYTE COMPARISON OF THE SPEC +// +// The obvious gate is "regenerate the spec and diff it". That was tried and +// does not hold: swag is not hermetic across environments. On a clean CI runner +// it emits `format: int64` for some map[string]int64 fields and not others, +// while the same swag version, the same Go toolchain (1.26.5) and a freshly +// downloaded module cache produce the format locally. The difference describes +// no disagreement about the API — both documents say the same thing about the +// same Go types — but a byte gate fails on it, cannot be satisfied by the +// developer who trips it, and would be weakened or deleted within a week. +// +// So gate the property the work was actually about. Every cross-repo bug that +// motivated this was structural: +// +// - 30 documented paths had no handler and no route anywhere in the repo +// (/admin/platform-agents, /plans, /tenants/{id}/subscription, ...), which +// is how the UI came to call GET /api/v1/me/event-types against a server +// that never had it, and to render OAuth buttons that 404'd. +// - 40 real endpoints were undocumented, the whole /notifications API among +// them, so no generated client could see them. +// +// Set comparison catches both exactly, and is stable across swag's formatting. +// +// THE THREE CHECKS +// +// A. annotations == spec +// Every // @Router in internal/infra/http/handler must appear as a +// path+method in the committed spec, and vice versa. This is what makes +// the spec "generated": you cannot hand-add a path, and you cannot change +// an annotation without running `make swagger`. +// +// B. spec ⊆ routes +// Every documented path+method must be registered on the router. This is +// the phantom-endpoint check. +// +// C. routes ⊆ spec ∪ baseline +// Every registered route must be documented, or listed in +// api/openapi/undocumented-routes.txt. 439 routes are undocumented today; +// annotating them is a large separate effort, so the baseline freezes that +// debt instead of ignoring it. A NEW route must be documented or must be +// added to the baseline deliberately, in the same commit, where a reviewer +// sees it. +package openapicontract + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// BasePath is the swagger `@BasePath`. Annotations and spec paths are relative +// to it; registered routes carry it literally. +const BasePath = "/api/v1" + +// Op is one HTTP operation, normalised for comparison: method upper-cased and +// path parameters reduced to {} so that {id} and {groupId} compare equal. +type Op struct { + Method string + Path string +} + +func (o Op) String() string { return o.Method + " " + o.Path } + +var paramRe = regexp.MustCompile(`\{[^}]*\}`) + +// NormalizePath reduces path parameters to a positional {} so that a rename of +// the parameter is not reported as a contract change. +func NormalizePath(p string) string { return paramRe.ReplaceAllString(p, "{}") } + +func norm(method, path string) Op { + return Op{Method: strings.ToUpper(method), Path: NormalizePath(path)} +} + +// rootOnlyPaths are registered on the root router rather than under BasePath, +// deliberately: liveness and readiness probes must not require a version prefix +// or auth. Swagger 2.0 has no per-operation basePath, so the spec renders them +// as /api/v1/health and /api/v1/ready. This is the one place the spec cannot be +// literally true, and it is recorded here rather than papered over. +var rootOnlyPaths = map[string]bool{ + "/health": true, + "/ready": true, +} + +// --------------------------------------------------------------------------- +// 1. @Router annotations +// --------------------------------------------------------------------------- + +var routerRe = regexp.MustCompile(`^\s*//\s*@Router\s+(\S+)\s+\[([a-zA-Z]+)\]`) + +// Annotations returns every // @Router operation declared under handlerDir. +func Annotations(handlerDir string) (map[Op]string, error) { + found := map[Op]string{} + err := filepath.Walk(handlerDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + data, err := os.ReadFile(path) //nolint:gosec // repo-local path from the caller + if err != nil { + return err + } + for i, line := range strings.Split(string(data), "\n") { + m := routerRe.FindStringSubmatch(line) + if m == nil { + continue + } + op := norm(m[2], m[1]) + found[op] = fmt.Sprintf("%s:%d", path, i+1) + } + return nil + }) + return found, err +} + +// --------------------------------------------------------------------------- +// 2. The committed spec +// --------------------------------------------------------------------------- + +var httpMethods = map[string]bool{ + "get": true, "post": true, "put": true, "patch": true, "delete": true, + "head": true, "options": true, +} + +// SpecOps returns every path+method declared in the committed OpenAPI document. +func SpecOps(specPath string) (map[Op]bool, error) { + data, err := os.ReadFile(specPath) //nolint:gosec // repo-local path from the caller + if err != nil { + return nil, err + } + var doc struct { + Paths map[string]map[string]yaml.Node `yaml:"paths"` + } + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parsing %s: %w", specPath, err) + } + ops := map[Op]bool{} + for p, methods := range doc.Paths { + for m := range methods { + if !httpMethods[strings.ToLower(m)] { + continue // parameters, $ref, x-* extensions + } + ops[norm(m, p)] = true + } + } + return ops, nil +} + +// --------------------------------------------------------------------------- +// 3. Registered routes +// --------------------------------------------------------------------------- + +var routeMethods = map[string]bool{ + "GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true, +} + +// Routes returns every route registered under routesDir, with its full path +// including the BasePath prefix the Group calls supply. +// +// Registration nests: router.Group("/api/v1/x", func(r Router) { r.GET("/y", h) }) +// so the walk carries the accumulated prefix down into each Group's function +// literal. Parsing the AST rather than grepping matters here — a regex cannot +// tell which Group a method call belongs to. +func Routes(routesDir string) (map[Op]string, error) { + entries, err := os.ReadDir(routesDir) + if err != nil { + return nil, err + } + fset := token.NewFileSet() + found := map[Op]string{} + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(routesDir, name), nil, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + collect(fset, fn.Body, "", found) + } + } + return found, nil +} + +func collect(fset *token.FileSet, n ast.Node, prefix string, out map[Op]string) { + ast.Inspect(n, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || len(call.Args) == 0 { + return true + } + lit, ok := call.Args[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + s, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + + switch { + case sel.Sel.Name == "Group" && len(call.Args) >= 2: + body, ok := call.Args[1].(*ast.FuncLit) + if !ok { + return true + } + collect(fset, body.Body, join(prefix, s), out) + return false // the recursion above already covered this subtree + case routeMethods[sel.Sel.Name]: + full := join(prefix, s) + out[norm(sel.Sel.Name, full)] = fset.Position(call.Pos()).String() + } + return true + }) +} + +func join(prefix, path string) string { + if path == "" || path == "/" { + if prefix == "" { + return "/" + } + return prefix + } + return strings.TrimSuffix(prefix, "/") + path +} + +// --------------------------------------------------------------------------- +// 4. The undocumented-route baseline +// --------------------------------------------------------------------------- + +// Baseline reads the frozen list of registered-but-undocumented routes. +// Blank lines and # comments are ignored. +func Baseline(path string) (map[Op]bool, error) { + data, err := os.ReadFile(path) //nolint:gosec // repo-local path from the caller + if err != nil { + return nil, err + } + out := map[Op]bool{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.Fields(line) + if len(parts) != 2 { + return nil, fmt.Errorf("malformed baseline line %q: want 'METHOD /path'", line) + } + out[norm(parts[0], parts[1])] = true + } + return out, nil +} + +// SpecToRoute maps a spec/annotation path onto the route path it should serve. +func SpecToRoute(specPath string) string { + if rootOnlyPaths[specPath] { + return specPath + } + return BasePath + specPath +} + +// RouteToSpec maps a registered route path back onto its spec path, reporting +// false when the route lives outside BasePath and is not a known root-only one. +func RouteToSpec(routePath string) (string, bool) { + if rootOnlyPaths[routePath] { + return routePath, true + } + if !strings.HasPrefix(routePath, BasePath+"/") { + return "", false + } + return strings.TrimPrefix(routePath, BasePath), true +} + +// SortedOps returns ops in a stable order for reporting. +func SortedOps[V any](m map[Op]V) []Op { + out := make([]Op, 0, len(m)) + for op := range m { + out = append(out, op) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Path != out[j].Path { + return out[i].Path < out[j].Path + } + return out[i].Method < out[j].Method + }) + return out +} diff --git a/tools/lint/openapicontract/contract_test.go b/tools/lint/openapicontract/contract_test.go new file mode 100644 index 00000000..0d96bffa --- /dev/null +++ b/tools/lint/openapicontract/contract_test.go @@ -0,0 +1,199 @@ +package openapicontract_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/openctemio/api/tools/lint/openapicontract" +) + +// repoRoot walks up from this package to the module root. +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + dir := wd + for range 10 { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Fatalf("could not find go.mod above %s", wd) + return "" +} + +func paths(t *testing.T) (handlerDir, routesDir, spec, baseline string) { + root := repoRoot(t) + return filepath.Join(root, "internal", "infra", "http", "handler"), + filepath.Join(root, "internal", "infra", "http", "routes"), + filepath.Join(root, "api", "openapi", "swagger.yaml"), + filepath.Join(root, "api", "openapi", "undocumented-routes.txt") +} + +// TestSpecMatchesAnnotations is what makes the spec a generated artifact rather +// than a hand-maintained document. It fails if a path was hand-added to +// swagger.yaml, and if a handler's @Router changed without `make swagger`. +// +// This replaces a byte-for-byte diff of the regenerated spec. That was tried +// and does not hold: swag emits `format: int64` for some map[string]int64 +// fields on a developer machine and not on a clean CI runner, with the same +// swag version, the same Go 1.26.5 and a freshly downloaded module cache. The +// two documents say the same thing about the same Go types, so a byte gate +// fails on a non-disagreement that the developer who trips it cannot fix. +func TestSpecMatchesAnnotations(t *testing.T) { + handlerDir, _, spec, _ := paths(t) + + ann, err := openapicontract.Annotations(handlerDir) + if err != nil { + t.Fatalf("reading @Router annotations: %v", err) + } + if len(ann) == 0 { + t.Fatal("found no @Router annotations — the scan is broken, not the code") + } + + specOps, err := openapicontract.SpecOps(spec) + if err != nil { + t.Fatalf("reading spec: %v", err) + } + if len(specOps) == 0 { + t.Fatal("spec declares no operations — the parse is broken, not the code") + } + + var missing []string + for _, op := range openapicontract.SortedOps(ann) { + if !specOps[op] { + missing = append(missing, op.String()+" (annotated at "+ann[op]+")") + } + } + if len(missing) > 0 { + t.Errorf("%d operation(s) are annotated but absent from the committed spec.\n"+ + "The spec is generated: run `make swagger` and commit the result.\n %s", + len(missing), strings.Join(missing, "\n ")) + } + + var extra []string + for _, op := range openapicontract.SortedOps(specOps) { + if _, ok := ann[op]; !ok { + extra = append(extra, op.String()) + } + } + if len(extra) > 0 { + t.Errorf("%d operation(s) are in the committed spec with no @Router annotation.\n"+ + "swagger.yaml is a GENERATED file — never hand-edit it. A path here that\n"+ + "no handler declares is exactly how the UI came to call\n"+ + "GET /api/v1/me/event-types against a server that never had it.\n"+ + "Run `make swagger` and commit the result.\n %s", + len(extra), strings.Join(extra, "\n ")) + } +} + +// TestEveryDocumentedPathIsRouted is the phantom-endpoint check. 30 paths in +// the pre-generation spec had no handler and no route anywhere in the +// repository — /admin/platform-agents, /plans, /tenants/{id}/subscription and +// friends, left over from a closed-source era. A client written against those +// gets a 404. +func TestEveryDocumentedPathIsRouted(t *testing.T) { + _, routesDir, spec, _ := paths(t) + + specOps, err := openapicontract.SpecOps(spec) + if err != nil { + t.Fatalf("reading spec: %v", err) + } + routes, err := openapicontract.Routes(routesDir) + if err != nil { + t.Fatalf("reading routes: %v", err) + } + if len(routes) == 0 { + t.Fatal("found no registered routes — the AST walk is broken, not the code") + } + + var phantom []string + for _, op := range openapicontract.SortedOps(specOps) { + want := openapicontract.Op{ + Method: op.Method, + Path: openapicontract.NormalizePath(openapicontract.SpecToRoute(op.Path)), + } + if _, ok := routes[want]; !ok { + phantom = append(phantom, op.String()+" (would need "+want.String()+")") + } + } + if len(phantom) > 0 { + t.Errorf("%d documented operation(s) have no registered route — a client\n"+ + "calling them gets a 404. Either register the route, or correct the\n"+ + "handler's @Router to the path it is really served on and rerun\n"+ + "`make swagger`.\n %s", + len(phantom), strings.Join(phantom, "\n ")) + } +} + +// TestEveryRouteIsDocumentedOrBaselined freezes the documentation debt. 439 +// registered routes carry no annotation today; annotating them is a separate +// effort. What must not happen again is a NEW endpoint shipping undocumented — +// that is how the entire /notifications API, GET /auth/providers and +// GET /scans/coverage stayed invisible to every generated client. +// +// Adding a route without an annotation therefore fails here, and the only way +// past is to add it to api/openapi/undocumented-routes.txt in the same commit, +// where a reviewer sees the choice. +func TestEveryRouteIsDocumentedOrBaselined(t *testing.T) { + _, routesDir, spec, baselinePath := paths(t) + + specOps, err := openapicontract.SpecOps(spec) + if err != nil { + t.Fatalf("reading spec: %v", err) + } + routes, err := openapicontract.Routes(routesDir) + if err != nil { + t.Fatalf("reading routes: %v", err) + } + baseline, err := openapicontract.Baseline(baselinePath) + if err != nil { + t.Fatalf("reading baseline: %v", err) + } + + var undocumented []string + for _, op := range openapicontract.SortedOps(routes) { + specPath, ok := openapicontract.RouteToSpec(op.Path) + if !ok { + // Outside /api/v1 and not a known probe: not part of the documented + // surface at all (websocket upgrades, static handlers). + continue + } + specOp := openapicontract.Op{Method: op.Method, Path: openapicontract.NormalizePath(specPath)} + if specOps[specOp] || baseline[op] { + continue + } + undocumented = append(undocumented, op.String()+" (registered at "+routes[op]+")") + } + if len(undocumented) > 0 { + t.Errorf("%d registered route(s) are neither documented nor baselined:\n %s\n\n"+ + "Add a // @Router annotation to the handler and run `make swagger`, or —\n"+ + "if documenting it now is genuinely out of scope — add the line to\n"+ + "api/openapi/undocumented-routes.txt so the choice is visible in review.", + len(undocumented), strings.Join(undocumented, "\n ")) + } + + // A baseline entry that no longer names a real route is stale: the route was + // removed or documented, and leaving it behind lets a future route slip in + // under a name that was already forgiven. + var stale []string + for _, op := range openapicontract.SortedOps(baseline) { + if _, ok := routes[op]; !ok { + stale = append(stale, op.String()) + } + } + if len(stale) > 0 { + t.Errorf("%d baseline entr(ies) no longer match a registered route — remove\n"+ + "them from api/openapi/undocumented-routes.txt:\n %s", + len(stale), strings.Join(stale, "\n ")) + } +}