Skip to content
Open
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
72 changes: 33 additions & 39 deletions internal/kitchen/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,31 +264,28 @@ func (h *Handler) handleAnalyze(w http.ResponseWriter, r *http.Request) {
h.broadcastAnalysisProgress(req, startedAt, analysisPhaseImport, formatImportPhaseMessage(result), "", result.ReposAnalyzed, result.ReposAnalyzed, len(result.SecretFindings))
if len(result.Findings) > 0 || len(result.Workflows) > 0 || len(result.SecretFindings) > 0 {
importStarted := time.Now()
imported := h.importAnalysisToPantry(result)
slog.Info("imported analysis to pantry",
"findings", len(result.Findings),
"workflows", len(result.Workflows),
"secrets", len(result.SecretFindings),
"assets", imported,
"duration", time.Since(importStarted))
imported := 0
h.broadcastAnalysisProgress(req, startedAt, analysisPhaseImport, "Persisting attack graph", "", result.ReposAnalyzed, result.ReposAnalyzed, len(result.SecretFindings))
if err := h.committedPantry().Replace(ctx, func(candidate *pantry.Pantry) error {
imported = h.importAnalysisToPantry(candidate, result)
return nil
}); err != nil {
slog.Warn("failed to commit analysis pantry", "target", req.Target, "error", err)
} else {
slog.Info("committed analysis to pantry",
"findings", len(result.Findings),
"workflows", len(result.Workflows),
"secrets", len(result.SecretFindings),
"assets", imported,
"revision", h.Pantry().Revision(),
"duration", time.Since(importStarted))
}
}

if err := h.persistAnalysisLoot(req, result); err != nil {
slog.Warn("failed to persist analysis loot", "target", req.Target, "error", err)
}

h.broadcastAnalysisProgress(req, startedAt, analysisPhaseImport, "Persisting attack graph", "", result.ReposAnalyzed, result.ReposAnalyzed, len(result.SecretFindings))
saveStarted := time.Now()
if err := h.SavePantry(); err != nil {
slog.Warn("failed to persist pantry", "error", err)
} else {
slog.Info("analysis pantry persisted",
"target", req.Target,
"assets", h.Pantry().Size(),
"edges", h.Pantry().EdgeCount(),
"duration", time.Since(saveStarted))
}

h.recordAnalysisCompleted(req, result)

// Return result
Expand Down Expand Up @@ -390,24 +387,21 @@ func (h *Handler) runAnalysisMetadataSync(req AnalyzeRequest, result *poutine.An
"duration", time.Since(visibilityStarted))

inventoryStarted := time.Now()
h.importPrivateReposToPantry(req.SessionID)
if err := h.importPrivateReposToPantry(ctx, req.SessionID); err != nil {
slog.Warn("failed to commit analysis private repository inventory", "target", req.Target, "error", err)
h.broadcastAnalysisMetadataSync(req, analysisMetadataFailed, "Repository access update incomplete", repoCount, err)
return
}
slog.Info("analysis private repo inventory updated",
"target", req.Target,
"session", req.SessionID,
"duration", time.Since(inventoryStarted))

saveStarted := time.Now()
if err := h.SavePantry(); err != nil {
slog.Warn("failed to persist pantry after analysis metadata sync", "target", req.Target, "error", err)
h.broadcastAnalysisMetadataSync(req, analysisMetadataFailed, "Repository access update incomplete", repoCount, err)
return
}

slog.Info("analysis metadata sync completed",
"target", req.Target,
"type", req.TargetType,
"repos", repoCount,
"persist_duration", time.Since(saveStarted),
"revision", h.Pantry().Revision(),
"duration", time.Since(started))
h.broadcastAnalysisMetadataSync(req, analysisMetadataDone, "Repository access updated", repoCount, nil)
}
Expand Down Expand Up @@ -566,8 +560,7 @@ func (o *analysisProgressObserver) broadcast(message, repo string, completedDelt
}

// importAnalysisToPantry imports poutine findings into the Kitchen's pantry.
func (h *Handler) importAnalysisToPantry(result *poutine.AnalysisResult) int {
p := h.Pantry()
func (h *Handler) importAnalysisToPantry(p *pantry.Pantry, result *poutine.AnalysisResult) int {
imported := 0
orgAssets := make(map[string]string)
repoAssets := make(map[string]string)
Expand Down Expand Up @@ -1106,20 +1099,21 @@ func (h *Handler) recordAnalyzedRepoVisibility(ctx context.Context, req AnalyzeR
}
}

func (h *Handler) importPrivateReposToPantry(sessionID string) {
func (h *Handler) importPrivateReposToPantry(ctx context.Context, sessionID string) error {
repo := db.NewKnownEntityRepository(h.database)
entities, err := repo.ListRepos(sessionID)
if err != nil {
slog.Warn("failed to list known entities for private repo import", "session", sessionID, "error", err)
return
return err
}
p := h.Pantry()
for _, entity := range entities {
if !entity.IsPrivate && entity.SSHPermission == "" && len(entity.Permissions) == 0 {
continue
return h.committedPantry().Update(ctx, func(candidate *pantry.Pantry) error {
for _, entity := range entities {
if !entity.IsPrivate && entity.SSHPermission == "" && len(entity.Permissions) == 0 {
continue
}
upsertKnownRepoAsset(candidate, entity)
}
upsertKnownRepoAsset(p, entity)
}
return nil
})
}

func upsertKnownRepoAsset(p *pantry.Pantry, entity *db.KnownEntityRow) {
Expand Down
19 changes: 9 additions & 10 deletions internal/kitchen/analyze_perf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

"github.com/stretchr/testify/require"

"github.com/boostsecurityio/smokedmeat/internal/pantry"
"github.com/boostsecurityio/smokedmeat/internal/poutine"
)

Expand Down Expand Up @@ -87,7 +88,11 @@ func TestAnalyzePerformanceProfile(t *testing.T) {

fmt.Printf("[perf] importing analysis results - elapsed=%s\n", roundPerfDuration(time.Since(totalStarted)))
importStarted := time.Now()
importedAssets := h.importAnalysisToPantry(result)
importedAssets := 0
require.NoError(t, h.committedPantry().Replace(ctx, func(candidate *pantry.Pantry) error {
importedAssets = h.importAnalysisToPantry(candidate, result)
return nil
}))
importDuration := time.Since(importStarted)

fmt.Printf("[perf] updating repository access - elapsed=%s\n", roundPerfDuration(time.Since(totalStarted)))
Expand All @@ -97,15 +102,10 @@ func TestAnalyzePerformanceProfile(t *testing.T) {

fmt.Printf("[perf] updating private repo inventory - elapsed=%s\n", roundPerfDuration(time.Since(totalStarted)))
inventoryStarted := time.Now()
h.importPrivateReposToPantry(config.SessionID)
require.NoError(t, h.importPrivateReposToPantry(ctx, config.SessionID))
inventoryDuration := time.Since(inventoryStarted)

fmt.Printf("[perf] persisting attack graph - elapsed=%s\n", roundPerfDuration(time.Since(totalStarted)))
persistStarted := time.Now()
require.NoError(t, h.SavePantry())
persistDuration := time.Since(persistStarted)

tailDuration := importDuration + secretScanDuration + repoAccessDuration + inventoryDuration + persistDuration
tailDuration := importDuration + secretScanDuration + repoAccessDuration + inventoryDuration
totalDuration := time.Since(totalStarted)
repoCount := len(collectAnalyzedRepos(result))

Expand All @@ -121,13 +121,12 @@ func TestAnalyzePerformanceProfile(t *testing.T) {
h.Pantry().Size(),
h.Pantry().EdgeCount(),
)
t.Logf("analysis timings scan=%s secret_scan=%s import=%s repo_access=%s private_repo_inventory=%s persist=%s tail=%s total=%s",
t.Logf("analysis timings scan=%s secret_scan=%s import_commit=%s repo_access=%s private_repo_inventory_commit=%s tail=%s total=%s",
scanDuration,
secretScanDuration,
importDuration,
repoAccessDuration,
inventoryDuration,
persistDuration,
tailDuration,
totalDuration,
)
Expand Down
31 changes: 18 additions & 13 deletions internal/kitchen/analyze_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -853,9 +853,14 @@ func TestImportPrivateReposToPantry_AddsPrivateRepos(t *testing.T) {
h := NewHandlerWithPublisher(mock, nil)
h.database = database

h.importPrivateReposToPantry("sess1")
require.NoError(t, h.importPrivateReposToPantry(context.Background(), "sess1"))

p := h.Pantry()
assert.Equal(t, uint64(1), p.Revision())
persisted, err := database.LoadPantry()
require.NoError(t, err)
require.NotNil(t, persisted)
assert.Equal(t, uint64(1), persisted.Revision())
repos := p.GetAssetsByType(pantry.AssetRepository)
assert.Len(t, repos, 2)

Expand All @@ -877,7 +882,7 @@ func TestImportPrivateReposToPantry_SkipsPublicRepos(t *testing.T) {
h := NewHandlerWithPublisher(mock, nil)
h.database = database

h.importPrivateReposToPantry("sess1")
require.NoError(t, h.importPrivateReposToPantry(context.Background(), "sess1"))

p := h.Pantry()
repos := p.GetAssetsByType(pantry.AssetRepository)
Expand All @@ -897,7 +902,7 @@ func TestImportPrivateReposToPantry_CreatesOrgAssets(t *testing.T) {
h := NewHandlerWithPublisher(mock, nil)
h.database = database

h.importPrivateReposToPantry("sess1")
require.NoError(t, h.importPrivateReposToPantry(context.Background(), "sess1"))

p := h.Pantry()

Expand Down Expand Up @@ -936,7 +941,7 @@ func TestImportPrivateReposToPantry_ImportsSSHAccessRepos(t *testing.T) {
h := NewHandlerWithPublisher(mock, nil)
h.database = database

h.importPrivateReposToPantry("sess1")
require.NoError(t, h.importPrivateReposToPantry(context.Background(), "sess1"))

repos := h.Pantry().GetAssetsByType(pantry.AssetRepository)
require.Len(t, repos, 1)
Expand Down Expand Up @@ -1021,7 +1026,7 @@ func TestHandleAnalyze_EmptySessionID_SkipsRepoVisibility(t *testing.T) {
// When SessionID is empty, this block is skipped entirely.
if req.SessionID != "" && h.database != nil {
h.recordAnalyzedRepoVisibility(t.Context(), req, result)
h.importPrivateReposToPantry(req.SessionID)
require.NoError(t, h.importPrivateReposToPantry(context.Background(), req.SessionID))
}

// Prove: no known entities recorded
Expand All @@ -1032,7 +1037,7 @@ func TestHandleAnalyze_EmptySessionID_SkipsRepoVisibility(t *testing.T) {

// Prove: pantry has no private property
p := h.Pantry()
h.importAnalysisToPantry(result)
h.importAnalysisToPantry(h.Pantry(), result)
repos := p.GetAssetsByType(pantry.AssetRepository)
for _, repo := range repos {
_, hasPrivate := repo.Properties["private"]
Expand Down Expand Up @@ -1079,7 +1084,7 @@ func TestHandleAnalyze_WithSessionID_RecordsRepoVisibility(t *testing.T) {
// Same guard as handleAnalyze
if req.SessionID != "" && h.database != nil {
h.recordAnalyzedRepoVisibility(t.Context(), req, result)
h.importPrivateReposToPantry(req.SessionID)
require.NoError(t, h.importPrivateReposToPantry(context.Background(), req.SessionID))
}

// Prove: entity recorded with IsPrivate=true
Expand Down Expand Up @@ -1177,7 +1182,7 @@ func TestImportAnalysisToPantry_SetsExploitSupportMetadata(t *testing.T) {
},
}

h.importAnalysisToPantry(result)
h.importAnalysisToPantry(h.Pantry(), result)

vulns := h.Pantry().FindVulnerabilities()
require.Len(t, vulns, 1)
Expand All @@ -1200,7 +1205,7 @@ func TestImportAnalysisToPantry_SkipsSelfHostedRunnerAnalyzeOnlyVuln(t *testing.
},
}

h.importAnalysisToPantry(result)
h.importAnalysisToPantry(h.Pantry(), result)

assert.Empty(t, h.Pantry().FindVulnerabilities())
targets := h.Pantry().GetAssetsByType(pantry.AssetSelfHostedRunner)
Expand Down Expand Up @@ -1238,7 +1243,7 @@ func TestImportAnalysisToPantry_CreatesSelfHostedRunnerTargets(t *testing.T) {
},
}

h.importAnalysisToPantry(result)
h.importAnalysisToPantry(h.Pantry(), result)

targets := h.Pantry().GetAssetsByType(pantry.AssetSelfHostedRunner)
require.Len(t, targets, 1)
Expand All @@ -1265,7 +1270,7 @@ func TestImportAnalysisToPantry_AttachesGitleaksSecretsToFindingRepo(t *testing.
},
}

h.importAnalysisToPantry(result)
h.importAnalysisToPantry(h.Pantry(), result)

secrets := h.Pantry().GetAssetsByType(pantry.AssetSecret)
require.Len(t, secrets, 1)
Expand Down Expand Up @@ -1295,7 +1300,7 @@ func TestImportAnalysisToPantry_PersistsBashContextBeforeExploitSupport(t *testi
},
}

h.importAnalysisToPantry(result)
h.importAnalysisToPantry(h.Pantry(), result)

vulns := h.Pantry().FindVulnerabilities()
require.Len(t, vulns, 1)
Expand Down Expand Up @@ -1338,7 +1343,7 @@ func TestImportAnalysisToPantry_PreservesMultiSourceInjectionFindings(t *testing
},
}

h.importAnalysisToPantry(result)
h.importAnalysisToPantry(h.Pantry(), result)

vulns := h.Pantry().FindVulnerabilities()
require.Len(t, vulns, 4)
Expand Down
61 changes: 61 additions & 0 deletions internal/kitchen/committed_pantry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (C) 2026 boostsecurity.io
// SPDX-License-Identifier: AGPL-3.0-or-later

package kitchen

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/boostsecurityio/smokedmeat/internal/pantry"
)

func TestHandlerKnownRepositoryCommitsOnePantryRevision(t *testing.T) {
database := newTestDB(t)
h := NewHandlerWithPublisher(&mockPublisher{}, nil)
h.SetDatabase(database)
body, err := json.Marshal(KnownEntityRequest{
ID: "repo:acme/api",
EntityType: "repo",
Name: "acme/api",
SessionID: "session-1",
DiscoveredVia: "analysis",
IsPrivate: true,
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/known-entities", bytes.NewReader(body))
rec := httptest.NewRecorder()

h.handlePostKnownEntities(rec, req)

assert.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, uint64(1), h.Pantry().Revision())
persisted, err := database.LoadPantry()
require.NoError(t, err)
require.NotNil(t, persisted)
assert.Equal(t, uint64(1), persisted.Revision())
repo, err := persisted.GetAsset("github:acme/api")
require.NoError(t, err)
assert.Equal(t, true, repo.Properties["private"])
}

func TestHandlerSetDatabaseRestoresCommittedPantryRevision(t *testing.T) {
database := newTestDB(t)
h := NewHandlerWithPublisher(&mockPublisher{}, nil)
h.SetDatabase(database)
require.NoError(t, h.committedPantry().Update(t.Context(), func(candidate *pantry.Pantry) error {
return candidate.AddAsset(pantry.NewOrganization("acme", "github"))
}))

restarted := NewHandlerWithPublisher(&mockPublisher{}, nil)
restarted.SetDatabase(database)

assert.Equal(t, uint64(1), restarted.Pantry().Revision())
assert.True(t, restarted.Pantry().HasAsset("github:org:acme"))
}
4 changes: 2 additions & 2 deletions internal/kitchen/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ var (
var schemaKey = []byte("schema")

const (
currentSchemaMajor = 2
currentSchemaMinor = 5
currentSchemaMajor = 3
currentSchemaMinor = 0
legacySchemaMajor = 1
legacySchemaMinor = 0
// Keep this string stable - quickstart readiness checks grep for it in Kitchen logs.
Expand Down
Loading
Loading