mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-16 08:57:30 +02:00
feat(工作流):支持本地图编排策略包导入导出 (#195)
* docs: define local workflow package mvp * docs: fix workflow package api contract * feat: add local workflow package mvp * feat(workflow): add package API client * feat(workflow): add package import interface * feat(workflow): connect package import flow * fix(workflow): map package validation errors * fix(workflow): handle import key generation errors * feat(workflow): localize package import states * fix(workflow): reset package import modal on open --------- Co-authored-by: ruanmingchen <“ruanm@chenchen”>
This commit is contained in:
@@ -120,6 +120,18 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
audit.RegisterConversationCreateHook(auditSvc)
|
||||
auditSvc.PurgeExpired()
|
||||
audit.StartRetentionLoop(auditSvc, log.Logger)
|
||||
if err := db.PurgeWorkflowPackageLifecycle(time.Now().UTC()); err != nil {
|
||||
log.Logger.Warn("清理过期工作流包记录失败", zap.Error(err))
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := db.PurgeWorkflowPackageLifecycle(time.Now().UTC()); err != nil {
|
||||
log.Logger.Warn("清理过期工作流包记录失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
monitorRetention := monitor.NewService(db, cfg, log.Logger)
|
||||
monitorRetention.PurgeExpired()
|
||||
@@ -1314,6 +1326,11 @@ func setupRoutes(
|
||||
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
||||
protected.POST("/workflows/validate", workflowHandler.Validate)
|
||||
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
||||
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
|
||||
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
|
||||
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
|
||||
protected.POST("/workflow-package-imports", workflowHandler.ApplyPackageImport)
|
||||
protected.GET("/workflow-package-imports/:importId", workflowHandler.GetPackageImport)
|
||||
protected.GET("/workflows", workflowHandler.List)
|
||||
protected.GET("/workflows/:id", workflowHandler.Get)
|
||||
protected.POST("/workflows", workflowHandler.Create)
|
||||
|
||||
@@ -667,6 +667,28 @@ func (db *DB) initTables() error {
|
||||
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
createWorkflowPackageInspectionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_package_inspections (
|
||||
id TEXT PRIMARY KEY, package_hash TEXT NOT NULL, manifest_json TEXT NOT NULL,
|
||||
workflow_payload_json TEXT NOT NULL, inspection_json TEXT NOT NULL,
|
||||
source_workflow_id TEXT NOT NULL, source_revision INTEGER NOT NULL,
|
||||
source_content_hash TEXT NOT NULL, source_graph_hash TEXT NOT NULL,
|
||||
local_conflict_state TEXT NOT NULL CHECK (local_conflict_state IN ('none','identical','id_conflict')),
|
||||
local_workflow_id TEXT, local_content_hash TEXT, local_graph_hash TEXT,
|
||||
created_by TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ready' CHECK (status IN ('ready','consumed','expired')),
|
||||
created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, consumed_at DATETIME
|
||||
);`
|
||||
createWorkflowPackageImportsTable := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_package_imports (
|
||||
id TEXT PRIMARY KEY, inspection_id TEXT NOT NULL, request_hash TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL, actor_user_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('create','keep_existing','overwrite','rename')),
|
||||
source_workflow_id TEXT NOT NULL, target_workflow_id TEXT NOT NULL, resulting_workflow_id TEXT,
|
||||
result TEXT NOT NULL CHECK (result IN ('created','overwritten','renamed','kept_existing','skipped_identical','failed')),
|
||||
error_code TEXT, error_message TEXT, created_at DATETIME NOT NULL, applied_at DATETIME,
|
||||
FOREIGN KEY (inspection_id) REFERENCES workflow_package_inspections(id)
|
||||
);`
|
||||
|
||||
// 创建索引
|
||||
createIndexes := `
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
||||
@@ -731,6 +753,9 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation ON workflow_runs(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run ON workflow_node_runs(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_package_inspections_creator_expiry ON workflow_package_inspections(created_by, expires_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_actor_key ON workflow_package_imports(actor_user_id, idempotency_key);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_inspection_success ON workflow_package_imports(inspection_id) WHERE result IN ('created','overwritten','renamed','kept_existing','skipped_identical');
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createConversationsTable); err != nil {
|
||||
@@ -830,9 +855,11 @@ func (db *DB) initTables() error {
|
||||
}
|
||||
|
||||
for tableName, ddl := range map[string]string{
|
||||
"workflow_definitions": createWorkflowDefinitionsTable,
|
||||
"workflow_runs": createWorkflowRunsTable,
|
||||
"workflow_node_runs": createWorkflowNodeRunsTable,
|
||||
"workflow_definitions": createWorkflowDefinitionsTable,
|
||||
"workflow_runs": createWorkflowRunsTable,
|
||||
"workflow_node_runs": createWorkflowNodeRunsTable,
|
||||
"workflow_package_inspections": createWorkflowPackageInspectionsTable,
|
||||
"workflow_package_imports": createWorkflowPackageImportsTable,
|
||||
} {
|
||||
if _, err := db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("创建%s表失败: %w", tableName, err)
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type WorkflowPackageInspection struct {
|
||||
ID, PackageHash, ManifestJSON, WorkflowPayloadJSON, InspectionJSON string
|
||||
SourceWorkflowID, SourceContentHash, SourceGraphHash string
|
||||
SourceRevision int
|
||||
LocalConflictState, LocalWorkflowID, LocalContentHash, LocalGraphHash string
|
||||
CreatedBy, Status string
|
||||
CreatedAt, ExpiresAt time.Time
|
||||
ConsumedAt *time.Time
|
||||
}
|
||||
|
||||
type WorkflowPackageImport struct {
|
||||
ID, InspectionID, RequestHash, IdempotencyKey, ActorUserID string
|
||||
Action, SourceWorkflowID, TargetWorkflowID, ResultingWorkflowID string
|
||||
Result, ErrorCode, ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
AppliedAt *time.Time
|
||||
}
|
||||
|
||||
type WorkflowPackageApplyRequest struct {
|
||||
InspectionID, RequestHash, IdempotencyKey, ActorUserID, Action, NewWorkflowID string
|
||||
ConfirmOverwrite bool
|
||||
}
|
||||
|
||||
type WorkflowPackageStoreError struct{ Code, Message string }
|
||||
|
||||
func (e *WorkflowPackageStoreError) Error() string { return e.Code + ": " + e.Message }
|
||||
func workflowPackageStoreError(code, message string) error {
|
||||
return &WorkflowPackageStoreError{code, message}
|
||||
}
|
||||
|
||||
func (db *DB) CreateWorkflowPackageInspection(v *WorkflowPackageInspection) error {
|
||||
if v == nil || strings.TrimSpace(v.ID) == "" || strings.TrimSpace(v.CreatedBy) == "" {
|
||||
return fmt.Errorf("workflow package inspection is incomplete")
|
||||
}
|
||||
if v.CreatedAt.IsZero() {
|
||||
v.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if v.ExpiresAt.IsZero() {
|
||||
v.ExpiresAt = v.CreatedAt.Add(30 * time.Minute)
|
||||
}
|
||||
_, err := db.Exec(`INSERT INTO workflow_package_inspections (id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,local_workflow_id,local_content_hash,local_graph_hash,created_by,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, v.ID, v.PackageHash, v.ManifestJSON, v.WorkflowPayloadJSON, v.InspectionJSON, v.SourceWorkflowID, v.SourceRevision, v.SourceContentHash, v.SourceGraphHash, v.LocalConflictState, nullString(v.LocalWorkflowID), nullString(v.LocalContentHash), nullString(v.LocalGraphHash), v.CreatedBy, "ready", v.CreatedAt.UTC(), v.ExpiresAt.UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkflowPackageInspection(id, actor string) (*WorkflowPackageInspection, error) {
|
||||
now := time.Now().UTC()
|
||||
_, _ = db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at <= ?`, now)
|
||||
row, err := scanWorkflowPackageInspection(db.QueryRow(`SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, strings.TrimSpace(id), strings.TrimSpace(actor)))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
func scanWorkflowPackageInspection(s interface{ Scan(...any) error }) (*WorkflowPackageInspection, error) {
|
||||
var v WorkflowPackageInspection
|
||||
var consumed sql.NullTime
|
||||
err := s.Scan(&v.ID, &v.PackageHash, &v.ManifestJSON, &v.WorkflowPayloadJSON, &v.InspectionJSON, &v.SourceWorkflowID, &v.SourceRevision, &v.SourceContentHash, &v.SourceGraphHash, &v.LocalConflictState, &v.LocalWorkflowID, &v.LocalContentHash, &v.LocalGraphHash, &v.CreatedBy, &v.Status, &v.CreatedAt, &v.ExpiresAt, &consumed)
|
||||
if consumed.Valid {
|
||||
t := consumed.Time
|
||||
v.ConsumedAt = &t
|
||||
}
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkflowPackageImport(id, actor string) (*WorkflowPackageImport, error) {
|
||||
v, err := scanWorkflowPackageImport(db.QueryRow(`SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE id=? AND actor_user_id=?`, strings.TrimSpace(id), strings.TrimSpace(actor)))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
func scanWorkflowPackageImport(s interface{ Scan(...any) error }) (*WorkflowPackageImport, error) {
|
||||
var v WorkflowPackageImport
|
||||
var applied sql.NullTime
|
||||
err := s.Scan(&v.ID, &v.InspectionID, &v.RequestHash, &v.IdempotencyKey, &v.ActorUserID, &v.Action, &v.SourceWorkflowID, &v.TargetWorkflowID, &v.ResultingWorkflowID, &v.Result, &v.ErrorCode, &v.ErrorMessage, &v.CreatedAt, &applied)
|
||||
if applied.Valid {
|
||||
t := applied.Time
|
||||
v.AppliedAt = &t
|
||||
}
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (db *DB) ApplyWorkflowPackageImport(ctx context.Context, req WorkflowPackageApplyRequest) (*WorkflowPackageImport, bool, error) {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var existingHash string
|
||||
previous, prevErr := scanWorkflowPackageImport(tx.QueryRowContext(ctx, `SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE actor_user_id=? AND idempotency_key=?`, req.ActorUserID, req.IdempotencyKey))
|
||||
if prevErr == nil {
|
||||
existingHash = previous.RequestHash
|
||||
if existingHash != req.RequestHash {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_IDEMPOTENCY_KEY_REUSED", "幂等键已用于其他请求")
|
||||
}
|
||||
return previous, true, nil
|
||||
}
|
||||
if prevErr != sql.ErrNoRows {
|
||||
return nil, false, prevErr
|
||||
}
|
||||
inspection, err := scanWorkflowPackageInspection(tx.QueryRowContext(ctx, `SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, req.InspectionID, req.ActorUserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_NOT_FOUND", "预检不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if !inspection.ExpiresAt.After(now) || inspection.Status == "expired" {
|
||||
_, _ = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='expired' WHERE id=?`, inspection.ID)
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_EXPIRED", "预检已过期")
|
||||
}
|
||||
if inspection.Status != "ready" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_CONSUMED", "预检已被使用")
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(inspection.WorkflowPayloadJSON), &payload); err != nil {
|
||||
return nil, false, fmt.Errorf("decode inspection payload: %w", err)
|
||||
}
|
||||
targetID := inspection.SourceWorkflowID
|
||||
if req.Action == "rename" {
|
||||
targetID = strings.TrimSpace(req.NewWorkflowID)
|
||||
if !validWorkflowPackageID(targetID) {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INVALID_RENAME_ID", "新工作流 ID 无效")
|
||||
}
|
||||
}
|
||||
sourceCurrent, err := scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", inspection.SourceWorkflowID))
|
||||
if err == sql.ErrNoRows {
|
||||
sourceCurrent = nil
|
||||
} else if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := checkWorkflowPackageSnapshot(inspection, sourceCurrent, inspection.SourceWorkflowID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
current := sourceCurrent
|
||||
if targetID != inspection.SourceWorkflowID {
|
||||
current, err = scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", targetID))
|
||||
if err == sql.ErrNoRows {
|
||||
current = nil
|
||||
} else if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
result := ""
|
||||
resultingID := ""
|
||||
switch req.Action {
|
||||
case "create":
|
||||
if inspection.LocalConflictState != "none" || current != nil {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "目标工作流已存在")
|
||||
}
|
||||
result = "created"
|
||||
resultingID = targetID
|
||||
case "keep_existing":
|
||||
if inspection.LocalConflictState == "none" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许保留本地")
|
||||
}
|
||||
if inspection.LocalConflictState == "identical" {
|
||||
result = "skipped_identical"
|
||||
} else {
|
||||
result = "kept_existing"
|
||||
}
|
||||
resultingID = targetID
|
||||
case "overwrite":
|
||||
if inspection.LocalConflictState != "id_conflict" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许覆盖")
|
||||
}
|
||||
if !req.ConfirmOverwrite {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_OVERWRITE_CONFIRMATION_REQUIRED", "覆盖需要确认")
|
||||
}
|
||||
result = "overwritten"
|
||||
resultingID = targetID
|
||||
case "rename":
|
||||
if inspection.LocalConflictState != "id_conflict" || current != nil {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许另存")
|
||||
}
|
||||
result = "renamed"
|
||||
resultingID = targetID
|
||||
default:
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INVALID_ACTION", "导入动作无效")
|
||||
}
|
||||
if result == "created" || result == "renamed" {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO workflow_definitions (id,name,description,version,graph_json,enabled,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`, resultingID, payload.Name, payload.Description, 1, payload.GraphJSON, boolToInt(payload.Enabled), now, now)
|
||||
} else if result == "overwritten" {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE workflow_definitions SET name=?,description=?,version=version+1,graph_json=?,enabled=?,updated_at=? WHERE id=?`, payload.Name, payload.Description, payload.GraphJSON, boolToInt(payload.Enabled), now, resultingID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
imp := &WorkflowPackageImport{ID: "wpii_" + strings.ReplaceAll(uuid.NewString(), "-", ""), InspectionID: inspection.ID, RequestHash: req.RequestHash, IdempotencyKey: req.IdempotencyKey, ActorUserID: req.ActorUserID, Action: req.Action, SourceWorkflowID: inspection.SourceWorkflowID, TargetWorkflowID: targetID, ResultingWorkflowID: resultingID, Result: result, CreatedAt: now, AppliedAt: &now}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO workflow_package_imports (id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,resulting_workflow_id,result,created_at,applied_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, imp.ID, imp.InspectionID, imp.RequestHash, imp.IdempotencyKey, imp.ActorUserID, imp.Action, imp.SourceWorkflowID, imp.TargetWorkflowID, nullString(imp.ResultingWorkflowID), imp.Result, now, now)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='consumed',consumed_at=? WHERE id=? AND status='ready'`, now, inspection.ID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return imp, false, nil
|
||||
}
|
||||
|
||||
func checkWorkflowPackageSnapshot(i *WorkflowPackageInspection, current *WorkflowDefinition, targetID string) error {
|
||||
if i.LocalConflictState == "none" {
|
||||
if current != nil {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if current == nil || current.ID != i.LocalWorkflowID || current.ID != targetID {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
content, graph := workflowDefinitionPackageHashes(current)
|
||||
if content != i.LocalContentHash || graph != i.LocalGraphHash {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func workflowDefinitionPackageHashes(w *WorkflowDefinition) (string, string) {
|
||||
var g any
|
||||
dec := json.NewDecoder(strings.NewReader(w.GraphJSON))
|
||||
dec.UseNumber()
|
||||
_ = dec.Decode(&g)
|
||||
graph, _ := json.Marshal(g)
|
||||
payload := struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}{w.ID, w.Name, w.Description, w.Version, string(graph), w.Enabled}
|
||||
b, _ := json.Marshal(payload)
|
||||
return workflowPackageHash(b), workflowPackageHash(graph)
|
||||
}
|
||||
func workflowPackageHash(b []byte) string {
|
||||
s := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(s[:])
|
||||
}
|
||||
func validWorkflowPackageID(id string) bool {
|
||||
if len(id) < 1 || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (db *DB) PurgeWorkflowPackageLifecycle(now time.Time) error {
|
||||
now = now.UTC()
|
||||
if _, err := db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at<=?`, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM workflow_package_inspections WHERE status='expired' AND expires_at<? AND NOT EXISTS (SELECT 1 FROM workflow_package_imports i WHERE i.inspection_id=workflow_package_inspections.id)`, now.Add(-24*time.Hour)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(`DELETE FROM workflow_package_imports WHERE created_at<?`, now.AddDate(0, 0, -90))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestWorkflowPackageApplyOverwriteIsTransactionalAndIdempotent(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
current := &WorkflowDefinition{ID: "wf-1", Name: "Local", Version: 12, GraphJSON: `{"nodes":[]}`, Enabled: true}
|
||||
if err := db.UpsertWorkflowDefinition(current); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, _ = db.GetWorkflowDefinition("wf-1")
|
||||
content, graph := workflowDefinitionPackageHashes(current)
|
||||
payload, _ := json.Marshal(map[string]any{"id": "wf-1", "name": "Imported", "description": "new", "version": 18, "graph_json": `{"nodes":[]}`, "enabled": false})
|
||||
now := time.Now().UTC()
|
||||
inspection := &WorkflowPackageInspection{ID: "wpi_test", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-1", SourceRevision: 18, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-1", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
|
||||
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-1", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true}
|
||||
imp, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
|
||||
if err != nil || replayed || imp.Result != "overwritten" {
|
||||
t.Fatalf("apply = %#v replay=%v err=%v", imp, replayed, err)
|
||||
}
|
||||
updated, _ := db.GetWorkflowDefinition("wf-1")
|
||||
if updated.Version != 13 || updated.Name != "Imported" || updated.Enabled {
|
||||
t.Fatalf("updated workflow = %#v", updated)
|
||||
}
|
||||
replay, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
|
||||
if err != nil || !replayed || replay.ID != imp.ID {
|
||||
t.Fatalf("replay = %#v replay=%v err=%v", replay, replayed, err)
|
||||
}
|
||||
gotInspection, err := db.GetWorkflowPackageInspection(inspection.ID, "user-1")
|
||||
if err != nil || gotInspection.Status != "consumed" {
|
||||
t.Fatalf("inspection=%#v err=%v", gotInspection, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowPackageApplyRejectsChangedConflictSnapshot(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package-conflict.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Local", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local, _ := db.GetWorkflowDefinition("wf-2")
|
||||
content, graph := workflowDefinitionPackageHashes(local)
|
||||
payload, _ := json.Marshal(map[string]any{"id": "wf-2", "name": "Imported", "version": 2, "graph_json": `{"nodes":[]}`, "enabled": true})
|
||||
now := time.Now().UTC()
|
||||
inspection := &WorkflowPackageInspection{ID: "wpi_changed", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-2", SourceRevision: 2, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-2", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
|
||||
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Changed", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = db.ApplyWorkflowPackageImport(context.Background(), WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-2", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true})
|
||||
if e, ok := err.(*WorkflowPackageStoreError); !ok || e.Code != "WFPKG_CONFLICT_CHANGED" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
workflowrunner "cyberstrike-ai/internal/workflow"
|
||||
workflowpkg "cyberstrike-ai/internal/workflow/package"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type workflowPackageResolution struct {
|
||||
Action string `json:"action"`
|
||||
NewWorkflowID string `json:"new_workflow_id"`
|
||||
}
|
||||
type workflowPackageImportRequest struct {
|
||||
InspectionID string `json:"inspection_id"`
|
||||
Resolution workflowPackageResolution `json:"resolution"`
|
||||
ConfirmOverwrite bool `json:"confirm_overwrite"`
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) ExportPackage(c *gin.Context) {
|
||||
wf, err := h.db.GetWorkflowDefinition(c.Param("id"))
|
||||
if err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_EXPORT_FAILED", "导出工作流包失败", nil)
|
||||
return
|
||||
}
|
||||
if wf == nil {
|
||||
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_WORKFLOW_NOT_FOUND", "工作流不存在", nil)
|
||||
return
|
||||
}
|
||||
pkg, meta, err := workflowpkg.Export(workflowPackageDocument(wf))
|
||||
if err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_EXPORT_FAILED", "导出工作流包失败", nil)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": meta.FileName}))
|
||||
c.Header("ETag", `"`+meta.PackageHash+`"`)
|
||||
c.Header("X-Workflow-Package-SHA256", meta.PackageHash)
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "workflow_package", "export", "导出工作流包", "workflow", wf.ID, map[string]interface{}{"package_hash": meta.PackageHash})
|
||||
}
|
||||
c.Data(http.StatusOK, "application/zip", pkg)
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) CreatePackageInspection(c *gin.Context) {
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok || strings.TrimSpace(session.UserID) == "" {
|
||||
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, workflowpkg.MaxArchiveBytes+1)
|
||||
file, _, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制", nil)
|
||||
return
|
||||
}
|
||||
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_REQUIRED", "必须上传工作流包文件", nil)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
archive, err := io.ReadAll(io.LimitReader(file, workflowpkg.MaxArchiveBytes+1))
|
||||
if err != nil || len(archive) > workflowpkg.MaxArchiveBytes {
|
||||
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制", nil)
|
||||
return
|
||||
}
|
||||
inspected, err := workflowpkg.InspectArchive(c.Request.Context(), archive, workflowrunner.ValidateGraphJSON)
|
||||
if err != nil {
|
||||
code := workflowpkg.ErrorCode(err)
|
||||
if code == "" {
|
||||
code = "WFPKG_INVALID_ARCHIVE"
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordFail(c, "workflow_package", "inspect", "工作流包预检失败", map[string]interface{}{"code": code, "package_hash": workflowPackageHash(archive)})
|
||||
}
|
||||
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, code, "工作流包预检失败", nil)
|
||||
return
|
||||
}
|
||||
state, local, err := h.workflowPackageConflict(inspected.Document.ID, inspected.ContentHash)
|
||||
if err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取本地工作流失败", nil)
|
||||
return
|
||||
}
|
||||
summary := workflowPackageInspectionSummary{ID: "wpi_" + strings.ReplaceAll(uuid.NewString(), "-", ""), Status: "ready", ExpiresAt: time.Now().UTC().Add(30 * time.Minute), Package: workflowPackagePackageSummary{PackageFormat: inspected.Manifest.PackageFormat, FormatVersion: inspected.Manifest.FormatVersion, PackageID: inspected.Manifest.PackageID, PackageHash: inspected.PackageHash}, Workflow: workflowPackageWorkflowSummary{SourceID: inspected.Document.ID, Name: inspected.Document.Name, Description: inspected.Document.Description, SourceRevision: inspected.Document.Version, Enabled: inspected.Document.Enabled, ContentHash: inspected.ContentHash, GraphHash: inspected.GraphHash, NodeCount: inspected.NodeCount, EdgeCount: inspected.EdgeCount}, Conflict: workflowPackageConflictSummary{State: state}, Warnings: []string{}}
|
||||
if local != nil {
|
||||
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(local))
|
||||
summary.Conflict.LocalWorkflow = &workflowPackageLocalWorkflow{ID: local.ID, Version: local.Version, ContentHash: content, GraphHash: graph}
|
||||
}
|
||||
manifestJSON, _ := json.Marshal(inspected.Manifest)
|
||||
payloadJSON, err := json.Marshal(inspected.Document)
|
||||
if err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "保存预检失败", nil)
|
||||
return
|
||||
}
|
||||
inspectionJSON, _ := json.Marshal(summary)
|
||||
record := &database.WorkflowPackageInspection{ID: summary.ID, PackageHash: inspected.PackageHash, ManifestJSON: string(manifestJSON), WorkflowPayloadJSON: string(payloadJSON), InspectionJSON: string(inspectionJSON), SourceWorkflowID: inspected.Document.ID, SourceRevision: inspected.Document.Version, SourceContentHash: inspected.ContentHash, SourceGraphHash: inspected.GraphHash, LocalConflictState: state, CreatedBy: session.UserID, CreatedAt: time.Now().UTC(), ExpiresAt: summary.ExpiresAt}
|
||||
if local != nil {
|
||||
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(local))
|
||||
record.LocalWorkflowID = local.ID
|
||||
record.LocalContentHash = content
|
||||
record.LocalGraphHash = graph
|
||||
}
|
||||
if err := h.db.CreateWorkflowPackageInspection(record); err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "保存预检失败", nil)
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "workflow_package", "inspect", "工作流包预检成功", "inspection", record.ID, map[string]interface{}{"package_hash": record.PackageHash, "workflow_id": record.SourceWorkflowID})
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"inspection": summary})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) GetPackageInspection(c *gin.Context) {
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok {
|
||||
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
|
||||
return
|
||||
}
|
||||
v, err := h.db.GetWorkflowPackageInspection(c.Param("inspectionId"), session.UserID)
|
||||
if err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取预检失败", nil)
|
||||
return
|
||||
}
|
||||
if v == nil {
|
||||
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_INSPECTION_NOT_FOUND", "预检不存在", nil)
|
||||
return
|
||||
}
|
||||
if v.Status == "expired" {
|
||||
writeWorkflowPackageError(c, http.StatusConflict, "WFPKG_INSPECTION_EXPIRED", "预检已过期", nil)
|
||||
return
|
||||
}
|
||||
var summary any
|
||||
_ = json.Unmarshal([]byte(v.InspectionJSON), &summary)
|
||||
c.JSON(http.StatusOK, gin.H{"inspection": summary})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) ApplyPackageImport(c *gin.Context) {
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok {
|
||||
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||
if _, err := uuid.Parse(key); err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusBadRequest, "WFPKG_IDEMPOTENCY_KEY_REQUIRED", "必须提供 UUID 幂等键", nil)
|
||||
return
|
||||
}
|
||||
var req workflowPackageImportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_INVALID_ACTION", "导入请求无效", nil)
|
||||
return
|
||||
}
|
||||
req.InspectionID = strings.TrimSpace(req.InspectionID)
|
||||
req.Resolution.Action = strings.TrimSpace(req.Resolution.Action)
|
||||
req.Resolution.NewWorkflowID = strings.TrimSpace(req.Resolution.NewWorkflowID)
|
||||
if req.Resolution.Action != "rename" && req.Resolution.NewWorkflowID != "" {
|
||||
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_INVALID_ACTION", "当前导入动作不接受新工作流 ID", nil)
|
||||
return
|
||||
}
|
||||
requestHash := workflowPackageRequestHash(req)
|
||||
imp, replayed, err := h.db.ApplyWorkflowPackageImport(c.Request.Context(), database.WorkflowPackageApplyRequest{InspectionID: req.InspectionID, RequestHash: requestHash, IdempotencyKey: key, ActorUserID: session.UserID, Action: req.Resolution.Action, NewWorkflowID: req.Resolution.NewWorkflowID, ConfirmOverwrite: req.ConfirmOverwrite})
|
||||
if err != nil {
|
||||
h.writeWorkflowPackageImportError(c, req.InspectionID, err)
|
||||
return
|
||||
}
|
||||
wf, _ := h.db.GetWorkflowDefinition(imp.ResultingWorkflowID)
|
||||
if !replayed && (imp.Result == "created" || imp.Result == "overwritten" || imp.Result == "renamed") {
|
||||
workflowrunner.InvalidateCompiledCache(imp.ResultingWorkflowID)
|
||||
}
|
||||
response := h.workflowPackageImportResponse(imp, wf)
|
||||
if !replayed && h.audit != nil {
|
||||
h.audit.RecordOK(c, "workflow_package", "import", "工作流包导入成功", "workflow", imp.ResultingWorkflowID, map[string]interface{}{"inspection_id": imp.InspectionID, "action": imp.Action, "result": imp.Result})
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if replayed {
|
||||
status = http.StatusOK
|
||||
}
|
||||
c.JSON(status, gin.H{"import": response})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) GetPackageImport(c *gin.Context) {
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok {
|
||||
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
|
||||
return
|
||||
}
|
||||
imp, err := h.db.GetWorkflowPackageImport(c.Param("importId"), session.UserID)
|
||||
if err != nil {
|
||||
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取导入结果失败", nil)
|
||||
return
|
||||
}
|
||||
if imp == nil {
|
||||
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_INSPECTION_NOT_FOUND", "导入结果不存在", nil)
|
||||
return
|
||||
}
|
||||
wf, _ := h.db.GetWorkflowDefinition(imp.ResultingWorkflowID)
|
||||
c.JSON(http.StatusOK, gin.H{"import": h.workflowPackageImportResponse(imp, wf)})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) workflowPackageConflict(id, sourceHash string) (string, *database.WorkflowDefinition, error) {
|
||||
local, err := h.db.GetWorkflowDefinition(id)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if local == nil {
|
||||
return "none", nil, nil
|
||||
}
|
||||
localHash, _, _, err := workflowpkg.DocumentHashes(workflowPackageDocument(local))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if localHash == sourceHash {
|
||||
return "identical", local, nil
|
||||
}
|
||||
return "id_conflict", local, nil
|
||||
}
|
||||
func workflowPackageDocument(w *database.WorkflowDefinition) workflowpkg.Document {
|
||||
return workflowpkg.Document{ID: w.ID, Name: w.Name, Description: w.Description, Version: w.Version, GraphJSON: w.GraphJSON, Enabled: w.Enabled, UpdatedAt: w.UpdatedAt}
|
||||
}
|
||||
func workflowPackageRequestHash(req workflowPackageImportRequest) string {
|
||||
value := struct {
|
||||
ConfirmOverwrite bool `json:"confirm_overwrite"`
|
||||
InspectionID string `json:"inspection_id"`
|
||||
Resolution workflowPackageResolution `json:"resolution"`
|
||||
}{req.ConfirmOverwrite, req.InspectionID, req.Resolution}
|
||||
b, _ := json.Marshal(value)
|
||||
sum := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
func workflowPackageHash(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) writeWorkflowPackageImportError(c *gin.Context, inspectionID string, err error) {
|
||||
var e *database.WorkflowPackageStoreError
|
||||
if errors.As(err, &e) {
|
||||
status := http.StatusConflict
|
||||
if e.Code == "WFPKG_INVALID_ACTION" || e.Code == "WFPKG_INVALID_RENAME_ID" {
|
||||
status = http.StatusUnprocessableEntity
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordFail(c, "workflow_package", "import", "工作流包导入失败", map[string]interface{}{"code": e.Code, "inspection_id": inspectionID})
|
||||
}
|
||||
writeWorkflowPackageError(c, status, e.Code, e.Message, nil)
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordFail(c, "workflow_package", "import", "工作流包导入失败", map[string]interface{}{"code": "WFPKG_IMPORT_FAILED", "inspection_id": inspectionID})
|
||||
}
|
||||
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "导入工作流包失败", nil)
|
||||
}
|
||||
func writeWorkflowPackageError(c *gin.Context, status int, code, message string, details map[string]any) {
|
||||
body := gin.H{"code": code, "message": message}
|
||||
if len(details) > 0 {
|
||||
body["details"] = details
|
||||
}
|
||||
c.JSON(status, gin.H{"error": body})
|
||||
}
|
||||
|
||||
type workflowPackageInspectionSummary struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Package workflowPackagePackageSummary `json:"package"`
|
||||
Workflow workflowPackageWorkflowSummary `json:"workflow"`
|
||||
Conflict workflowPackageConflictSummary `json:"conflict"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
type workflowPackagePackageSummary struct {
|
||||
PackageFormat string `json:"package_format"`
|
||||
FormatVersion string `json:"format_version"`
|
||||
PackageID string `json:"package_id"`
|
||||
PackageHash string `json:"package_hash"`
|
||||
}
|
||||
type workflowPackageWorkflowSummary struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SourceRevision int `json:"source_revision"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
GraphHash string `json:"graph_hash"`
|
||||
NodeCount int `json:"node_count"`
|
||||
EdgeCount int `json:"edge_count"`
|
||||
}
|
||||
type workflowPackageConflictSummary struct {
|
||||
State string `json:"state"`
|
||||
LocalWorkflow *workflowPackageLocalWorkflow `json:"local_workflow,omitempty"`
|
||||
}
|
||||
type workflowPackageLocalWorkflow struct {
|
||||
ID string `json:"id"`
|
||||
Version int `json:"version"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
GraphHash string `json:"graph_hash"`
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) workflowPackageImportResponse(imp *database.WorkflowPackageImport, wf *database.WorkflowDefinition) gin.H {
|
||||
out := gin.H{"id": imp.ID, "inspection_id": imp.InspectionID, "status": "succeeded", "result": imp.Result, "action": imp.Action, "source_workflow_id": imp.SourceWorkflowID, "target_workflow_id": imp.TargetWorkflowID, "applied_at": imp.AppliedAt}
|
||||
if wf != nil {
|
||||
content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(wf))
|
||||
out["workflow"] = gin.H{"id": wf.ID, "version": wf.Version, "content_hash": content, "graph_hash": graph}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
workflowpkg "cyberstrike-ai/internal/workflow/package"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "workflow-package-handler.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
h := NewWorkflowHandler(db, zap.NewNop())
|
||||
pkg, _, err := workflowpkg.Export(workflowpkg.Document{ID: "wf-api", Name: "API workflow", Version: 4, Enabled: true, UpdatedAt: time.Now().UTC(), GraphJSON: `{"nodes":[{"id":"start-1","type":"start","label":"开始","position":{"x":0,"y":0},"config":{}},{"id":"out-1","type":"output","label":"输出","position":{"x":0,"y":120},"config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"id":"e1","source":"start-1","target":"out-1"}],"config":{"schema_version":1}}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "wf-api.csapkg.zip")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(pkg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflow-package-inspections", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: "user-1"})
|
||||
h.CreatePackageInspection(c)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("inspection status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var inspected struct {
|
||||
Inspection struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"inspection"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &inspected); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyBody := bytes.NewBufferString(`{"inspection_id":"` + inspected.Inspection.ID + `","resolution":{"action":"create","new_workflow_id":""},"confirm_overwrite":false}`)
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflow-package-imports", applyBody)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("Idempotency-Key", uuid.NewString())
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: "user-1"})
|
||||
h.ApplyPackageImport(c)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("import status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
saved, _ := db.GetWorkflowDefinition("wf-api")
|
||||
if saved == nil || saved.Version != 1 {
|
||||
t.Fatalf("saved=%#v", saved)
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,10 @@ func permissionForRequest(method, fullPath string) string {
|
||||
return crudPermission(method, "files")
|
||||
case strings.HasPrefix(path, "/roles"):
|
||||
return crudPermission(method, "roles")
|
||||
case path == "/workflows/:id/package":
|
||||
return "workflow:read"
|
||||
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
|
||||
return "workflow:write"
|
||||
case strings.HasPrefix(path, "/workflows"):
|
||||
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
|
||||
return "workflow:execute"
|
||||
@@ -257,6 +261,9 @@ func isProcessGlobalMutationPath(path string) bool {
|
||||
// Workflow runs inherit conversation access; definitions are global.
|
||||
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run"
|
||||
}
|
||||
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/knowledge") {
|
||||
return path != "/knowledge/search"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkflowPackageRoutesHaveExplicitWorkflowPermissions(t *testing.T) {
|
||||
if got := permissionForRequest(http.MethodGet, "/api/workflows/:id/package"); got != "workflow:read" {
|
||||
t.Fatalf("export permission=%q", got)
|
||||
}
|
||||
for _, path := range []string{"/api/workflow-package-inspections", "/api/workflow-package-inspections/:inspectionId", "/api/workflow-package-imports", "/api/workflow-package-imports/:importId"} {
|
||||
if got := permissionForRequest(http.MethodGet, path); got != "workflow:write" {
|
||||
t.Fatalf("%s permission=%q", path, got)
|
||||
}
|
||||
}
|
||||
if !isProcessGlobalMutationPath("/workflow-package-imports") || !isProcessGlobalMutationPath("/workflow-package-inspections") {
|
||||
t.Fatal("package mutations must require all-resource scope")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Export builds a deterministic, human-readable single-workflow package.
|
||||
func Export(source Document) ([]byte, ExportMetadata, error) {
|
||||
source.ID = strings.TrimSpace(source.ID)
|
||||
source.Name = strings.TrimSpace(source.Name)
|
||||
if source.ID == "" || source.Name == "" || source.Version <= 0 || !safePackageWorkflowID(source.ID) {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("workflow id, name and version are required")
|
||||
}
|
||||
if strings.TrimSpace(source.GraphJSON) == "" {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("workflow graph_json is required")
|
||||
}
|
||||
contentHash, graphHash, payload, err := DocumentHashes(source)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, err
|
||||
}
|
||||
workflowPath := path.Join("workflows", source.ID+".json")
|
||||
createdAt := source.UpdatedAt.UTC()
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Unix(0, 0).UTC()
|
||||
}
|
||||
manifest := Manifest{
|
||||
PackageFormat: PackageFormat,
|
||||
FormatVersion: FormatVersion,
|
||||
PackageID: "pkg_" + strings.TrimPrefix(contentHash, "sha256:")[:16],
|
||||
CreatedAt: createdAt.Format(time.RFC3339),
|
||||
Items: []ManifestItem{{
|
||||
Type: "workflow",
|
||||
Path: workflowPath,
|
||||
SourceID: source.ID,
|
||||
SourceRevision: source.Version,
|
||||
ContentHash: contentHash,
|
||||
GraphHash: graphHash,
|
||||
}},
|
||||
}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
checksums := fmt.Sprintf("%s manifest.json\n%s %s\n", strings.TrimPrefix(sha256Prefixed(manifestBytes), "sha256:"), strings.TrimPrefix(contentHash, "sha256:"), workflowPath)
|
||||
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{name: "checksums.sha256", data: []byte(checksums)},
|
||||
{name: "manifest.json", data: manifestBytes},
|
||||
{name: workflowPath, data: payload},
|
||||
} {
|
||||
header := &zip.FileHeader{Name: entry.name, Method: zip.Store}
|
||||
header.SetModTime(time.Unix(0, 0).UTC())
|
||||
writer, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err)
|
||||
}
|
||||
if _, err := writer.Write(entry.data); err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("close package: %w", err)
|
||||
}
|
||||
pkg := out.Bytes()
|
||||
return pkg, ExportMetadata{
|
||||
PackageHash: sha256Prefixed(pkg),
|
||||
ContentHash: contentHash,
|
||||
GraphHash: graphHash,
|
||||
SourceRevision: source.Version,
|
||||
FileName: source.ID + ".csapkg.zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DocumentHashes returns the canonical package item and graph hashes together
|
||||
// with the canonical item bytes used by export and inspection persistence.
|
||||
func DocumentHashes(source Document) (string, string, []byte, error) {
|
||||
graph, err := canonicalJSON([]byte(source.GraphJSON))
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("canonicalize graph_json: %w", err)
|
||||
}
|
||||
source.GraphJSON = string(graph)
|
||||
payload, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("marshal workflow payload: %w", err)
|
||||
}
|
||||
return sha256Prefixed(payload), sha256Prefixed(graph), payload, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testDocument() Document {
|
||||
return Document{
|
||||
ID: "web-src-hunting",
|
||||
Name: "Web SRC 猎洞",
|
||||
Description: "面向 SRC Web 资产的侦察与漏洞候选流程",
|
||||
Version: 18,
|
||||
Enabled: true,
|
||||
GraphJSON: `{"nodes":[{"id":"start-1","type":"start","label":"开始","position":{"x":0,"y":0},"config":{}},{"id":"out-1","type":"output","label":"输出","position":{"x":0,"y":120},"config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"id":"e1","source":"start-1","target":"out-1"}],"config":{"schema_version":1}}`,
|
||||
UpdatedAt: time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportIsDeterministicAndSelfDescribing(t *testing.T) {
|
||||
first, firstMeta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatalf("first export: %v", err)
|
||||
}
|
||||
second, secondMeta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatalf("second export: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("identical document must produce byte-identical package")
|
||||
}
|
||||
if firstMeta.PackageHash != secondMeta.PackageHash || !strings.HasPrefix(firstMeta.PackageHash, "sha256:") {
|
||||
t.Fatalf("unexpected deterministic package hash: %#v / %#v", firstMeta, secondMeta)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(first), int64(len(first)))
|
||||
if err != nil {
|
||||
t.Fatalf("open package: %v", err)
|
||||
}
|
||||
if len(zr.File) != 3 {
|
||||
t.Fatalf("zip entry count = %d, want 3", len(zr.File))
|
||||
}
|
||||
wantNames := []string{"checksums.sha256", "manifest.json", "workflows/web-src-hunting.json"}
|
||||
for i, f := range zr.File {
|
||||
if f.Name != wantNames[i] {
|
||||
t.Fatalf("entry %d = %q, want %q", i, f.Name, wantNames[i])
|
||||
}
|
||||
}
|
||||
if firstMeta.SourceRevision != 18 {
|
||||
t.Fatalf("source revision = %d, want 18", firstMeta.SourceRevision)
|
||||
}
|
||||
if !strings.HasPrefix(firstMeta.ContentHash, "sha256:") || !strings.HasPrefix(firstMeta.GraphHash, "sha256:") {
|
||||
t.Fatalf("content/graph hashes must be sha256: %#v", firstMeta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxArchiveBytes = 10 << 20
|
||||
MaxExtractedBytes = 20 << 20
|
||||
)
|
||||
|
||||
// PackageError contains only a contract error code and safe, client-facing fields.
|
||||
type PackageError struct {
|
||||
Code string
|
||||
Message string
|
||||
Details map[string]any
|
||||
}
|
||||
|
||||
func (e *PackageError) Error() string { return e.Code + ": " + e.Message }
|
||||
|
||||
func packageError(code, message string) error {
|
||||
return &PackageError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// ErrorCode returns a package contract code without exposing internal errors.
|
||||
func ErrorCode(err error) string {
|
||||
var target *PackageError
|
||||
if errors.As(err, &target) {
|
||||
return target.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type InspectionResult struct {
|
||||
PackageHash string
|
||||
Manifest Manifest
|
||||
Document Document
|
||||
ContentHash string
|
||||
GraphHash string
|
||||
NodeCount int
|
||||
EdgeCount int
|
||||
}
|
||||
|
||||
// InspectArchive verifies an archive without executing any package content.
|
||||
// validateGraph is injected by the application so this format package has no
|
||||
// dependency on the workflow runtime or database driver.
|
||||
func InspectArchive(ctx context.Context, archive []byte, validateGraph func(context.Context, string) error) (*InspectionResult, error) {
|
||||
if len(archive) == 0 {
|
||||
return nil, packageError("WFPKG_FILE_REQUIRED", "必须上传工作流包文件")
|
||||
}
|
||||
if len(archive) > MaxArchiveBytes {
|
||||
return nil, packageError("WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制")
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包不是有效 ZIP 文件")
|
||||
}
|
||||
entries := make(map[string][]byte, len(zr.File))
|
||||
var extracted int64
|
||||
for _, file := range zr.File {
|
||||
if !safeArchivePath(file.Name) || file.FileInfo().IsDir() || file.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含不安全文件路径")
|
||||
}
|
||||
if _, exists := entries[file.Name]; exists {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含重复文件")
|
||||
}
|
||||
if file.UncompressedSize64 > MaxExtractedBytes || extracted+int64(file.UncompressedSize64) > MaxExtractedBytes {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制")
|
||||
}
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "无法读取工作流包文件")
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(reader, int64(MaxExtractedBytes)-extracted+1))
|
||||
closeErr := reader.Close()
|
||||
if readErr != nil || closeErr != nil || len(data) > MaxExtractedBytes-int(extracted) {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制")
|
||||
}
|
||||
extracted += int64(len(data))
|
||||
entries[file.Name] = data
|
||||
}
|
||||
|
||||
manifestRaw, hasManifest := entries["manifest.json"]
|
||||
checksumsRaw, hasChecksums := entries["checksums.sha256"]
|
||||
if !hasManifest || !hasChecksums {
|
||||
return nil, packageError("WFPKG_UNSUPPORTED_FORMAT", "工作流包缺少必需文件")
|
||||
}
|
||||
manifest, err := parseManifest(manifestRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(manifest.Items) != 1 || manifest.Items[0].Type != "workflow" {
|
||||
return nil, packageError("WFPKG_MULTIPLE_WORKFLOWS", "工作流包必须且只能包含一个工作流")
|
||||
}
|
||||
item := manifest.Items[0]
|
||||
workflowRaw, exists := entries[item.Path]
|
||||
if !exists || !safeWorkflowPath(item.Path) || len(entries) != 3 {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含未声明文件")
|
||||
}
|
||||
checksums, err := parseChecksums(checksumsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(checksums) != 2 || checksums["manifest.json"] != sha256Prefixed(manifestRaw) || checksums[item.Path] != sha256Prefixed(workflowRaw) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和不匹配")
|
||||
}
|
||||
if item.ContentHash != sha256Prefixed(workflowRaw) || !validHash(item.ContentHash) || !validHash(item.GraphHash) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包内容校验和不匹配")
|
||||
}
|
||||
doc, err := parseDocument(workflowRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !safePackageWorkflowID(doc.ID) || doc.ID != item.SourceID || doc.Version != item.SourceRevision {
|
||||
return nil, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单与工作流内容不一致")
|
||||
}
|
||||
graph, err := canonicalJSON([]byte(doc.GraphJSON))
|
||||
if err != nil || item.GraphHash != sha256Prefixed(graph) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流图校验和不匹配")
|
||||
}
|
||||
if validateGraph == nil || validateGraph(ctx, string(graph)) != nil {
|
||||
return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图校验失败")
|
||||
}
|
||||
var graphShape struct {
|
||||
Nodes []json.RawMessage `json:"nodes"`
|
||||
Edges []json.RawMessage `json:"edges"`
|
||||
}
|
||||
if err := json.Unmarshal(graph, &graphShape); err != nil {
|
||||
return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图不是有效 JSON")
|
||||
}
|
||||
return &InspectionResult{
|
||||
PackageHash: sha256Prefixed(archive),
|
||||
Manifest: manifest,
|
||||
Document: doc,
|
||||
ContentHash: item.ContentHash,
|
||||
GraphHash: item.GraphHash,
|
||||
NodeCount: len(graphShape.Nodes),
|
||||
EdgeCount: len(graphShape.Edges),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func safeArchivePath(name string) bool {
|
||||
return name != "" && !strings.Contains(name, `\`) && !strings.HasPrefix(name, "/") && path.Clean(name) == name && !strings.HasPrefix(name, "../") && name != ".."
|
||||
}
|
||||
|
||||
func safeWorkflowPath(name string) bool {
|
||||
rest := strings.TrimPrefix(name, "workflows/")
|
||||
return safeArchivePath(name) && strings.HasPrefix(name, "workflows/") && rest != "" && !strings.Contains(rest, "/") && strings.HasSuffix(rest, ".json")
|
||||
}
|
||||
|
||||
func safePackageWorkflowID(id string) bool {
|
||||
if id == "" || strings.ContainsAny(id, `/\`) {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseManifest(raw []byte) (Manifest, error) {
|
||||
var manifest Manifest
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&manifest); err != nil {
|
||||
return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式无效")
|
||||
}
|
||||
if err := consumeJSONEnd(dec); err != nil || manifest.PackageFormat != PackageFormat || manifest.FormatVersion != FormatVersion || strings.TrimSpace(manifest.PackageID) == "" || len(manifest.Items) == 0 {
|
||||
return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式不受支持")
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func parseDocument(raw []byte) (Document, error) {
|
||||
var doc Document
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&doc); err != nil || consumeJSONEnd(dec) != nil {
|
||||
return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义格式无效")
|
||||
}
|
||||
doc.ID = strings.TrimSpace(doc.ID)
|
||||
doc.Name = strings.TrimSpace(doc.Name)
|
||||
if doc.ID == "" || doc.Name == "" || doc.Version <= 0 || strings.TrimSpace(doc.GraphJSON) == "" {
|
||||
return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义缺少必需字段")
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func consumeJSONEnd(dec *json.Decoder) error {
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseChecksums(raw []byte) (map[string]string, error) {
|
||||
entries := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
|
||||
if len(parts) != 2 || !validHash("sha256:"+parts[0]) || !safeArchivePath(parts[1]) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和格式无效")
|
||||
}
|
||||
if _, exists := entries[parts[1]]; exists {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和重复")
|
||||
}
|
||||
entries[parts[1]] = "sha256:" + parts[0]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInspectArchiveAcceptsSingleVerifiedWorkflow(t *testing.T) {
|
||||
pkg, meta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("InspectArchive: %v", err)
|
||||
}
|
||||
if result.PackageHash != meta.PackageHash || result.Document.ID != "web-src-hunting" {
|
||||
t.Fatalf("unexpected inspection: %#v", result)
|
||||
}
|
||||
if result.NodeCount != 2 || result.EdgeCount != 1 {
|
||||
t.Fatalf("counts = %d/%d, want 2/1", result.NodeCount, result.EdgeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectArchiveRejectsUnsafeArchiveShapes(t *testing.T) {
|
||||
valid, _, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
archive []byte
|
||||
}{
|
||||
{name: "duplicate entry", archive: appendZipEntry(t, valid, "manifest.json", []byte(`{}`), 0)},
|
||||
{name: "path traversal", archive: appendZipEntry(t, valid, "../payload.json", []byte(`{}`), 0)},
|
||||
{name: "symlink", archive: appendZipEntry(t, valid, "workflows/link.json", []byte("target"), 0o120777)},
|
||||
{name: "undeclared file", archive: appendZipEntry(t, valid, "notes.txt", []byte("not allowed"), 0)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := InspectArchive(context.Background(), tc.archive, func(context.Context, string) error { return nil })
|
||||
if ErrorCode(err) != "WFPKG_INVALID_ARCHIVE" {
|
||||
t.Fatalf("code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectArchiveRejectsChecksumMismatchAndInvalidWorkflow(t *testing.T) {
|
||||
pkg, _, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badChecksum := replaceZipEntry(t, pkg, "checksums.sha256", []byte("00 manifest.json\n"), 0)
|
||||
if _, err := InspectArchive(context.Background(), badChecksum, func(context.Context, string) error { return nil }); ErrorCode(err) != "WFPKG_CHECKSUM_MISMATCH" {
|
||||
t.Fatalf("checksum code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
if _, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return errors.New("invalid graph") }); ErrorCode(err) != "WFPKG_WORKFLOW_INVALID" {
|
||||
t.Fatalf("graph code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func appendZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte {
|
||||
t.Helper()
|
||||
return rewriteZip(t, archive, func(zw *zip.Writer) error {
|
||||
h := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
h.SetMode(mode)
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func replaceZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
h := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
h.SetMode(mode)
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &zip.FileHeader{Name: f.Name, Method: zip.Store}
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err == nil {
|
||||
_, err = io.Copy(w, r)
|
||||
}
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func rewriteZip(t *testing.T, archive []byte, appendEntry func(*zip.Writer) error) []byte {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, f := range zr.File {
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &zip.FileHeader{Name: f.Name, Method: zip.Store}
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err == nil {
|
||||
_, err = io.Copy(w, r)
|
||||
}
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := appendEntry(zw); err != nil {
|
||||
t.Fatal(fmt.Errorf("append entry: %w", err))
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PackageFormat = "cyberstrikeai.workflow-package"
|
||||
FormatVersion = "1.0"
|
||||
)
|
||||
|
||||
// Document is the single non-executable workflow definition carried by a package.
|
||||
// Version is the source instance revision and is never applied as a target version.
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
PackageFormat string `json:"package_format"`
|
||||
FormatVersion string `json:"format_version"`
|
||||
PackageID string `json:"package_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Items []ManifestItem `json:"items"`
|
||||
}
|
||||
|
||||
type ManifestItem struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceRevision int `json:"source_revision"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
GraphHash string `json:"graph_hash"`
|
||||
}
|
||||
|
||||
type ExportMetadata struct {
|
||||
PackageHash string
|
||||
ContentHash string
|
||||
GraphHash string
|
||||
SourceRevision int
|
||||
FileName string
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.UseNumber()
|
||||
var value any
|
||||
if err := dec.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dec.More() {
|
||||
return nil, fmt.Errorf("extra JSON values")
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func sha256Prefixed(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func validHash(value string) bool {
|
||||
if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
|
||||
return err == nil && value == strings.ToLower(value)
|
||||
}
|
||||
Reference in New Issue
Block a user