Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ increment provides:
when releases are created.
- A built-in operational status GUI, editable source configuration forms, and a
JSON status endpoint with recent feed/API activity logs.
- A CT Ops connector API that accepts signed inventory snapshots, matches
installed packages, and delivers signed finding batches back to CT Ops.

## Local Development

Expand Down Expand Up @@ -54,6 +56,13 @@ Recent feed sync outcomes and source configuration API changes are recorded as
operational logs and shown on the status page and JSON endpoint. Logs report
whether an NVD API key is configured but never include the key value.

CT Ops pushes inventory to CT-CVE at
`POST /api/v1/ct-ops/inventory-snapshots`. CT-CVE verifies the signed service
request, stores the org-scoped host and software inventory, immediately matches
active packages against the persisted vulnerability catalog, and posts finding
batches back to CT Ops at
`/api/integrations/ct-cve/v1/finding-batches`.

## Container Images

Release-please manages CT-CVE GitHub releases from Conventional Commit history.
Expand Down Expand Up @@ -84,14 +93,45 @@ published release.
| `CT_CVE_NVD_REQUEST_DELAY` | No | `6s` without an API key, `600ms` with an API key | Minimum delay between NVD API requests. |
| `CT_CVE_CISA_KEV_ENABLED` | No | `true` | Enables the CISA Known Exploited Vulnerabilities source. |
| `CT_CVE_CISA_KEV_BASE_URL` | No | `https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json` | CISA KEV JSON feed URL. |
| `CT_CVE_CT_OPS_CONNECTIONS` | No | `[]` | JSON array of CT Ops connector definitions with org-scoped inbound inventory tokens and outbound CT Ops callback token. |

The status page reports whether the NVD API key is configured, but it never
returns the key value in HTML or JSON responses.

Example CT Ops connector configuration:

```json
[
{
"name": "Primary CT Ops",
"orgId": "org_123",
"ctOpsBaseUrl": "https://ctops.example.com",
"inventoryTokens": [
{
"id": "ctops-inventory",
"secret": "replace-with-at-least-32-bytes-of-secret",
"scopes": ["inventory:write", "connection:read"]
}
],
"ctOpsToken": {
"id": "ctcve-outbound",
"secret": "replace-with-at-least-32-bytes-of-secret",
"scopes": ["findings:write", "connection:read"]
}
}
]
```

The same HMAC request-signing contract is used in both directions:
`Authorization: CT-ServiceToken <token-id>`, `X-CT-Timestamp`, `X-CT-Nonce`,
`X-CT-Content-SHA256`, and `X-CT-Signature: v1=<base64url-hmac-sha256>`.

## Migration Status

This bootstrap now includes feed sync workers for the NVD and CISA KEV catalogs,
including persistence of CVE metadata, known-exploited CVE metadata, and source
status, plus the first CT-CVE status GUI/API slice with editable source
configuration and feed/API activity logs. CT-CVE subscription status from CT Ops
and the CT Ops connector remain outstanding migration work.
configuration and feed/API activity logs. The first CT Ops connector path is
implemented for signed inventory ingestion, immediate matching, and signed
finding delivery. CT-CVE subscription status from CT Ops remains outstanding
migration work.
6 changes: 6 additions & 0 deletions cmd/ct-cve/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/carrtech-dev/ct-cve/internal/config"
"github.com/carrtech-dev/ct-cve/internal/ctops"
"github.com/carrtech-dev/ct-cve/internal/feed"
"github.com/carrtech-dev/ct-cve/internal/gui"
"github.com/carrtech-dev/ct-cve/internal/store"
Expand Down Expand Up @@ -49,6 +50,11 @@ func main() {

mux := http.NewServeMux()
gui.NewHandler(cfg, db).Register(mux)
ctops.NewHandler(ctops.HandlerOptions{
Connections: cfg.CTOpsConnections,
Store: db,
HTTPClient: &http.Client{Timeout: cfg.FeedHTTPTimeout},
}).Register(mux)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
Expand Down
1 change: 1 addition & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ services:
CT_CVE_NVD_ENABLED: "true"
CT_CVE_NVD_REQUEST_DELAY: 6s
CT_CVE_CISA_KEV_ENABLED: "true"
CT_CVE_CT_OPS_CONNECTIONS: "[]"
ports:
- "8080:8080"
depends_on:
Expand Down
121 changes: 121 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
package config

import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"

"github.com/carrtech-dev/ct-cve/internal/ctops"
)

const serviceTokenMinBytes = 32

type rawCTOpsConnection struct {
Name string `json:"name"`
OrgID string `json:"orgId"`
CTOpsBaseURL string `json:"ctOpsBaseUrl"`
InventoryTokens []rawCTOpsServiceToken `json:"inventoryTokens"`
CTOpsToken rawCTOpsServiceToken `json:"ctOpsToken"`
}

type rawCTOpsServiceToken struct {
ID string `json:"id"`
Secret string `json:"secret"`
Scopes []string `json:"scopes"`
Revoked bool `json:"revoked"`
}

type Config struct {
HTTPAddr string
DatabaseURL string
FeedSyncInterval time.Duration
FeedSyncOnStartup bool
FeedHTTPTimeout time.Duration
Sources SourceConfig
CTOpsConnections []ctops.Connection
}

type SourceConfig struct {
Expand Down Expand Up @@ -83,13 +104,18 @@ func Load() (Config, error) {
if err != nil {
return Config{}, err
}
ctOpsConnections, err := ctOpsConnectionsFromEnv(os.Getenv("CT_CVE_CT_OPS_CONNECTIONS"))
if err != nil {
return Config{}, err
}

cfg := Config{
HTTPAddr: valueOrDefault(os.Getenv("CT_CVE_HTTP_ADDR"), ":8080"),
DatabaseURL: strings.TrimSpace(os.Getenv("CT_CVE_DATABASE_URL")),
FeedSyncInterval: feedSyncInterval,
FeedSyncOnStartup: feedSyncOnStartup,
FeedHTTPTimeout: feedHTTPTimeout,
CTOpsConnections: ctOpsConnections,
Sources: SourceConfig{
NVD: NVDSourceConfig{
Enabled: nvdEnabled,
Expand All @@ -109,6 +135,101 @@ func Load() (Config, error) {
return cfg, nil
}

func ctOpsConnectionsFromEnv(value string) ([]ctops.Connection, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
var raw []rawCTOpsConnection
if err := json.Unmarshal([]byte(value), &raw); err != nil {
return nil, fmt.Errorf("CT_CVE_CT_OPS_CONNECTIONS must be valid JSON: %w", err)
}
connections := make([]ctops.Connection, 0, len(raw))
for index, entry := range raw {
path := fmt.Sprintf("CT_CVE_CT_OPS_CONNECTIONS[%d]", index)
name := strings.TrimSpace(entry.Name)
orgID := strings.TrimSpace(entry.OrgID)
if name == "" || orgID == "" {
return nil, fmt.Errorf("%s must include name and orgId", path)
}
baseURL, err := normalizeBaseURL(entry.CTOpsBaseURL, path+".ctOpsBaseUrl")
if err != nil {
return nil, err
}
inventoryTokens := make([]ctops.ServiceToken, 0, len(entry.InventoryTokens))
for tokenIndex, rawToken := range entry.InventoryTokens {
token, err := parseServiceToken(rawToken, orgID, fmt.Sprintf("%s.inventoryTokens[%d]", path, tokenIndex))
if err != nil {
return nil, err
}
inventoryTokens = append(inventoryTokens, token)
}
if len(inventoryTokens) == 0 {
return nil, fmt.Errorf("%s.inventoryTokens must include at least one token", path)
}
ctOpsToken, err := parseServiceToken(entry.CTOpsToken, orgID, path+".ctOpsToken")
if err != nil {
return nil, err
}
connections = append(connections, ctops.Connection{
Name: name,
OrgID: orgID,
CTOpsBaseURL: baseURL,
InventoryTokens: inventoryTokens,
CTOpsToken: ctOpsToken,
})
}
return connections, nil
}

func parseServiceToken(raw rawCTOpsServiceToken, orgID, path string) (ctops.ServiceToken, error) {
id := strings.TrimSpace(raw.ID)
if id == "" || raw.Secret == "" {
return ctops.ServiceToken{}, fmt.Errorf("%s must include id and secret", path)
}
if !secretHasEnoughEntropy(raw.Secret) {
return ctops.ServiceToken{}, fmt.Errorf("%s.secret must contain at least 32 bytes of entropy", path)
}
scopes := make([]ctops.ServiceTokenScope, 0, len(raw.Scopes))
for _, rawScope := range raw.Scopes {
scope := ctops.ServiceTokenScope(strings.TrimSpace(rawScope))
switch scope {
case ctops.ScopeInventoryWrite, ctops.ScopeFindingsWrite, ctops.ScopeConnectionRead:
scopes = append(scopes, scope)
default:
return ctops.ServiceToken{}, fmt.Errorf("%s.scopes contains unsupported scope %q", path, rawScope)
}
}
if len(scopes) == 0 {
return ctops.ServiceToken{}, fmt.Errorf("%s.scopes must include at least one scope", path)
}
return ctops.ServiceToken{
ID: id,
Secret: raw.Secret,
OrgID: orgID,
Scopes: scopes,
Revoked: raw.Revoked,
}, nil
}

func normalizeBaseURL(value, name string) (string, error) {
parsedURL, err := validateHTTPURL(name, strings.TrimSpace(value))
if err != nil {
return "", err
}
parsed, err := url.Parse(parsedURL)
if err != nil {
return "", err
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/"), nil
}

func secretHasEnoughEntropy(value string) bool {
return len(value) >= serviceTokenMinBytes
}

func (cfg Config) ApplySourceSettings(settings []SourceSettings) Config {
next := cfg
for _, setting := range settings {
Expand Down
74 changes: 74 additions & 0 deletions internal/config/ctops_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package config

import (
"testing"

"github.com/carrtech-dev/ct-cve/internal/ctops"
)

func TestLoadParsesCTOpsConnections(t *testing.T) {
t.Setenv("CT_CVE_DATABASE_URL", "postgres://ct_cve:ct_cve@localhost:5432/ct_cve?sslmode=disable")
t.Setenv("CT_CVE_CT_OPS_CONNECTIONS", `[
{
"name": "Primary CT Ops",
"orgId": "org_123",
"ctOpsBaseUrl": "https://ctops.example.test/",
"inventoryTokens": [
{
"id": "ctops-inventory",
"secret": "ctops inventory signing secret 12345",
"scopes": ["inventory:write"]
}
],
"ctOpsToken": {
"id": "ctcve-outbound",
"secret": "ctcve outbound signing secret 12345",
"scopes": ["findings:write", "connection:read"]
}
}
]`)

cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.CTOpsConnections) != 1 {
t.Fatalf("connections = %d, want 1", len(cfg.CTOpsConnections))
}
connection := cfg.CTOpsConnections[0]
if connection.Name != "Primary CT Ops" {
t.Fatalf("Name = %q", connection.Name)
}
if connection.CTOpsBaseURL != "https://ctops.example.test" {
t.Fatalf("CTOpsBaseURL = %q, want normalized URL", connection.CTOpsBaseURL)
}
if len(connection.InventoryTokens) != 1 || connection.InventoryTokens[0].Scopes[0] != ctops.ScopeInventoryWrite {
t.Fatalf("InventoryTokens = %#v", connection.InventoryTokens)
}
if connection.CTOpsToken.ID != "ctcve-outbound" || len(connection.CTOpsToken.Scopes) != 2 {
t.Fatalf("CTOpsToken = %#v", connection.CTOpsToken)
}
}

func TestLoadRejectsInvalidCTOpsConnectionConfig(t *testing.T) {
tests := []struct {
name string
value string
}{
{name: "not json", value: `{nope`},
{name: "missing org", value: `[{"name":"x","ctOpsBaseUrl":"https://ctops.example.test"}]`},
{name: "non http url", value: `[{"name":"x","orgId":"org_123","ctOpsBaseUrl":"file:///tmp/x"}]`},
{name: "weak token", value: `[{"name":"x","orgId":"org_123","ctOpsBaseUrl":"https://ctops.example.test","inventoryTokens":[{"id":"t","secret":"short","scopes":["inventory:write"]}]}]`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("CT_CVE_DATABASE_URL", "postgres://ct_cve:ct_cve@localhost:5432/ct_cve?sslmode=disable")
t.Setenv("CT_CVE_CT_OPS_CONNECTIONS", tt.value)

if _, err := Load(); err == nil {
t.Fatal("Load returned nil error, want invalid CT Ops connection error")
}
})
}
}
Loading
Loading