feat: add SAST and Image Scan node types with configuration forms

- Implemented SAST node type for static analysis with configurable tools (Trivy, Semgrep, Gitleaks, SonarCloud, custom).
- Added Image Scan node type for scanning built container images for CVEs and secrets.
- Enhanced NodeRow component to display scan results and push summaries.
- Updated node catalog to include new node types with default configurations.
This commit is contained in:
patel-lyzr
2026-05-13 22:15:08 +05:30
parent 034b4cac88
commit 22a0fc694d
7 changed files with 1704 additions and 1 deletions
+319
View File
@@ -0,0 +1,319 @@
package executors
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/lyzrai/flow/pkg/engine"
"github.com/lyzrai/flow/pkg/models"
)
// ImageScanExecutor scans an OCI image (already pushed to a registry) for
// CVEs, malware, embedded secrets, and base-image vulnerabilities. Pairs
// with a Build node upstream — ImageScan reads `__build.image` from input
// items by default — but can also scan a hand-specified ref.
//
// This is distinct from the SAST node: SAST scans *source*, ImageScan
// scans the *built artifact*. They catch different classes of bugs (a
// vulnerable transitive dep that only appears in the final layer; a
// secret baked into a layer; a malicious base image).
//
// Tools (sibling docker containers):
// - trivy — `trivy image <ref>` — vulns + secrets + misconfig in image
// - grype — `grype <ref>` — Anchore's CVE scanner
// - custom — your container, your command. We mount nothing; it's your
// tool's job to pull whatever it needs.
//
// Parameters:
// - tool trivy | grype | custom (default: trivy)
// - imageRef string (optional; defaults to upstream __build.image)
// - severityThreshold LOW | MEDIUM | HIGH | CRITICAL (default: HIGH)
// - failOnFinding bool (default: true)
// - timeoutSeconds number (default: 600)
// - registryUsername string for private source registries
// - registryPassword string
// - insecure bool accept plain-HTTP / self-signed (default: true; matches local registry)
// - dockerNetwork string override compose network (default: langship-restate_default)
// - custom only:
// image string scanner image
// command string shell command (image ref is exported as $IMAGE_REF)
type ImageScanExecutor struct{}
func (e *ImageScanExecutor) Execute(ctx context.Context, node models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) {
logger := engine.NodeLoggerFromContext(ctx)
// --- resolve image ref ---
ref := strParam(node.Parameters, "imageRef", "")
if ref == "" {
ref = imageFromBuildOutput(inputs)
}
if ref == "" {
return nil, errors.New("imageScan: no imageRef (set explicitly or wire a Build node upstream)")
}
tool := strings.ToLower(strParam(node.Parameters, "tool", "trivy"))
threshold := strings.ToUpper(strParam(node.Parameters, "severityThreshold", "HIGH"))
failOnFinding := boolParam(node.Parameters, "failOnFinding", true)
timeoutSec := intParam(node.Parameters, "timeoutSeconds", 600)
if timeoutSec < 30 {
timeoutSec = 30
}
if timeoutSec > 3600 {
timeoutSec = 3600
}
insecure := boolParam(node.Parameters, "insecure", true)
username := strParam(node.Parameters, "registryUsername", "")
password := strParam(node.Parameters, "registryPassword", "")
dockerNetwork := strParam(node.Parameters, "dockerNetwork", "langship-restate_default")
hardCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
defer cancel()
// --- registry-network ref rewrite ---
// Build pushes to `registry:5000/...` (the compose service name) but the
// flow process running on the host stamps `__build.image` with that same
// ref. From the host, `registry` doesn't resolve — but we run the scanner
// inside the compose network where it does. So we leave the ref intact
// and run docker on the same network. If the ref is a localhost/127.x
// address, swap to `registry:5000` so the scanner-in-network can reach it.
scanRef := registryNetworkRef(ref)
logger.Log(fmt.Sprintf("[imagescan:%s] scanning %s", tool, scanRef))
// --- optional auth via mounted docker config ---
var configMount []string
if username != "" || password != "" {
dir, cleanup, err := writeDockerConfig(scanRef, username, password)
if err != nil {
return nil, fmt.Errorf("imageScan: write docker config: %w", err)
}
defer cleanup()
configMount = []string{"-v", dir + ":/root/.docker:ro"}
}
var (
findings []sastFinding
toolErr error
)
switch tool {
case "trivy":
findings, toolErr = runTrivyImage(hardCtx, scanRef, threshold, insecure, configMount, dockerNetwork, logger)
case "grype":
findings, toolErr = runGrype(hardCtx, scanRef, insecure, configMount, dockerNetwork, logger)
case "custom":
findings, toolErr = runCustomImage(hardCtx, node.Parameters, scanRef, dockerNetwork, logger)
default:
return nil, fmt.Errorf("imageScan: unknown tool %q", tool)
}
if toolErr != nil {
return nil, fmt.Errorf("imageScan (%s): %w", tool, toolErr)
}
counts := map[string]int{}
for _, f := range findings {
counts[strings.ToUpper(f.Severity)]++
}
logger.Log(fmt.Sprintf("[imagescan:%s] %d finding(s) %v", tool, len(findings), counts))
out := map[string]any{
"tool": tool,
"imageRef": ref, // original ref users care about
"scanRef": scanRef, // rewritten ref the scanner used
"severityThreshold": threshold,
"counts": counts,
"finding_count": len(findings),
"findings": findings,
"finished_at": time.Now().UTC(),
}
items := make([]models.Item, 0)
for _, in := range inputs {
for _, it := range in {
ci := copyItem(it)
ci["__imageScan"] = out
items = append(items, ci)
}
}
if len(items) == 0 {
items = append(items, models.Item{"__imageScan": out})
}
if failOnFinding && exceedsThreshold(findings, threshold) {
return nil, fmt.Errorf("imageScan (%s) failed: severity threshold %s exceeded (%v)",
tool, threshold, counts)
}
return map[int][]models.Item{0: items}, nil
}
// --- trivy image ---------------------------------------------------------
func runTrivyImage(ctx context.Context, ref, threshold string, insecure bool, configMount []string, network string, logger engine.NodeLogger) ([]sastFinding, error) {
args := []string{"run", "--rm", "--network", network}
args = append(args, configMount...)
args = append(args,
"aquasec/trivy:latest",
"image", "--quiet",
"--format", "json",
"--severity", severityChainAtOrAbove(threshold),
"--scanners", "vuln,secret",
)
if insecure {
args = append(args, "--insecure")
}
args = append(args, ref)
out, err := dockerRunCapture(ctx, args, logger)
if err != nil && len(out) == 0 {
return nil, err
}
return parseTrivy(out) // same JSON shape as `trivy fs`
}
// --- grype ---------------------------------------------------------------
func runGrype(ctx context.Context, ref string, insecure bool, configMount []string, network string, logger engine.NodeLogger) ([]sastFinding, error) {
args := []string{"run", "--rm", "--network", network}
args = append(args, configMount...)
if insecure {
// grype trusts the docker-config "insecureRegistries" too, but the
// quickest knob is its env var.
args = append(args, "-e", "GRYPE_REGISTRY_INSECURE_USE_HTTP=true")
args = append(args, "-e", "GRYPE_REGISTRY_INSECURE_SKIP_TLS_VERIFY=true")
}
args = append(args,
"anchore/grype:latest",
ref,
"-o", "json",
)
out, err := dockerRunCapture(ctx, args, logger)
if err != nil && len(out) == 0 {
return nil, err
}
return parseGrype(out)
}
type grypeReport struct {
Matches []struct {
Vulnerability struct {
ID string `json:"id"`
Severity string `json:"severity"`
} `json:"vulnerability"`
Artifact struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"artifact"`
} `json:"matches"`
}
func parseGrype(raw []byte) ([]sastFinding, error) {
if i := bytes.IndexByte(raw, '{'); i > 0 {
raw = raw[i:]
}
var r grypeReport
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("parse grype json: %w", err)
}
out := make([]sastFinding, 0, len(r.Matches))
for _, m := range r.Matches {
out = append(out, sastFinding{
Tool: "grype",
Severity: strings.ToUpper(m.Vulnerability.Severity),
RuleID: m.Vulnerability.ID,
Message: fmt.Sprintf("%s in %s@%s",
m.Vulnerability.ID, m.Artifact.Name, m.Artifact.Version),
})
}
return out, nil
}
// --- custom --------------------------------------------------------------
func runCustomImage(ctx context.Context, p map[string]any, ref, network string, logger engine.NodeLogger) ([]sastFinding, error) {
image := strings.TrimSpace(strParam(p, "image", ""))
command := strings.TrimSpace(strParam(p, "command", ""))
if image == "" || command == "" {
return nil, errors.New("custom imageScan requires image and command")
}
args := []string{
"run", "--rm", "--network", network,
"-e", "IMAGE_REF=" + ref,
image,
"/bin/sh", "-c", command,
}
out, err := dockerRunCapture(ctx, args, logger)
if err != nil {
return nil, fmt.Errorf("custom scanner exit: %w (last: %s)", err, oneLineSummary(string(out)))
}
return []sastFinding{{
Tool: "custom",
Severity: "UNKNOWN",
Message: lastLines(string(out), 20),
}}, nil
}
// --- helpers -------------------------------------------------------------
// registryNetworkRef rewrites localhost-flavoured registry refs so the
// scanner-in-network can reach the bundled `registry:5000`. Anything else
// passes through.
func registryNetworkRef(ref string) string {
for _, host := range []string{"localhost:5000", "127.0.0.1:5000", "host.docker.internal:5000"} {
if strings.HasPrefix(ref, host+"/") {
return "registry:5000" + ref[len(host):]
}
}
return ref
}
// writeDockerConfig drops a docker config.json into a temp dir so the
// scanner container picks up creds when mounted at /root/.docker. The host
// of the registry is parsed off the image ref. Returns (dir, cleanup, err).
func writeDockerConfig(ref, username, password string) (string, func(), error) {
host := registryHostFromRef(ref)
if host == "" {
return "", func() {}, errors.New("could not parse registry host from image ref")
}
dir, err := os.MkdirTemp("", "flow-imagescan-")
if err != nil {
return "", func() {}, err
}
cleanup := func() { _ = os.RemoveAll(dir) }
// docker-config "auths" expects the credential as base64(user:pass).
authB64 := base64Encode(username + ":" + password)
cfg := fmt.Sprintf(`{"auths":{%q:{"auth":%q}}}`, host, authB64)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfg), 0o600); err != nil {
cleanup()
return "", func() {}, err
}
return dir, cleanup, nil
}
// registryHostFromRef returns the registry hostname (and optional port)
// from an OCI ref. "registry:5000/owner/img:tag" → "registry:5000".
func registryHostFromRef(ref string) string {
i := strings.Index(ref, "/")
if i < 0 {
return ""
}
head := ref[:i]
// A bare path with no host (e.g. "library/alpine:latest") shouldn't get
// here — Docker treats those as docker.io.
if !strings.Contains(head, ".") && !strings.Contains(head, ":") && head != "localhost" {
return ""
}
return head
}
// base64Encode wraps stdlib for symmetry with the docker-config writer.
func base64Encode(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
+18
View File
@@ -81,6 +81,12 @@ func (e *PushExecutor) Execute(ctx context.Context, node models.NodeDef, inputs
if srcImage == "" {
return nil, errors.New("push: no source image (set srcImage or wire a Build node upstream)")
}
// Build pushes to `registry:5000` (the compose service name), but Push
// runs in the flow process on the host where `registry` doesn't
// resolve. Rewrite to `localhost:5000` so crane.Pull can reach the
// published host port. Symmetric to the rewrite ImageScan does in the
// other direction.
srcImage = pushSourceHostRef(srcImage)
if _, err := name.ParseReference(srcImage); err != nil {
return nil, fmt.Errorf("push: invalid src %q: %w", srcImage, err)
}
@@ -313,6 +319,18 @@ func defaultName(explicit, registry string, idx int) string {
return host
}
// pushSourceHostRef rewrites a compose-internal registry hostname to its
// host-published equivalent so the host-running flow process can actually
// reach the registry. Only the most common pair we set up
// (`registry:5000` → `localhost:5000`) is rewritten; everything else
// passes through unchanged.
func pushSourceHostRef(ref string) string {
if strings.HasPrefix(ref, "registry:5000/") {
return "localhost:5000" + ref[len("registry:5000"):]
}
return ref
}
func anyToBool(v any, def bool) bool {
switch x := v.(type) {
case bool:
+3 -1
View File
@@ -56,9 +56,11 @@ func RegisterAll(deps ...RegistryDeps) {
// Human-in-the-loop
Register("flow-nodes-base.waitForApproval", &ApprovalExecutor{})
// CI/CD primitives — Build + Push are real; the rest are stubs for now.
// CI/CD primitives — Build + Push + SAST are real; the rest are stubs.
Register("flow-nodes-base.build", &BuildExecutor{Agents: d.Agents})
Register("flow-nodes-base.push", &PushExecutor{})
Register("flow-nodes-base.sast", &SastExecutor{Agents: d.Agents})
Register("flow-nodes-base.imageScan", &ImageScanExecutor{})
Register("flow-nodes-base.test", &TestExecutor{})
Register("flow-nodes-base.eval", &EvalExecutor{})
Register("flow-nodes-base.policy", &PolicyExecutor{})
+629
View File
@@ -0,0 +1,629 @@
package executors
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"strings"
"time"
"github.com/lyzrai/flow/pkg/engine"
"github.com/lyzrai/flow/pkg/models"
"github.com/lyzrai/flow/pkg/storage"
)
// SastExecutor runs static-analysis security scanning against the agent's
// source repo. The `tool` parameter selects which scanner runs; each tool
// is just a sibling docker container we shell out to (`docker run --rm`),
// except `sonar` which uploads to SonarCloud and polls for the quality
// gate verdict.
//
// Trigger payload requirements (same as Build):
// - agentId string (provided by dispatchAgent / webhook)
// - commit string commit SHA to check out (optional)
// - ref string ref override
//
// Common parameters:
// - tool trivy | semgrep | gitleaks | sonar | custom (default: trivy)
// - severityThreshold LOW | MEDIUM | HIGH | CRITICAL (default: HIGH)
// - failOnFinding bool (default: true)
// - timeoutSeconds number (default: 600)
//
// Tool-specific parameters:
// - trivy / semgrep / gitleaks no extra config — sane defaults
// - sonar:
// sonarHost default https://sonarcloud.io
// organization required
// projectKey required
// sonarToken required (SONAR_TOKEN)
// branchName optional, defaults to the agent ref
// - custom:
// image OCI image to run (required)
// command shell command inside the container (required)
type SastExecutor struct {
Agents storage.AgentStore
}
func (e *SastExecutor) Execute(ctx context.Context, node models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) {
if e.Agents == nil {
return nil, errors.New("sast: AgentStore not configured")
}
logger := engine.NodeLoggerFromContext(ctx)
trigger := firstItem(inputs)
agentID, _ := trigger["agentId"].(string)
if agentID == "" {
return nil, errors.New("sast: trigger payload missing agentId")
}
a, err := e.Agents.Get(ctx, agentID)
if err != nil {
return nil, fmt.Errorf("sast: load agent %q: %w", agentID, err)
}
tool := strings.ToLower(strParam(node.Parameters, "tool", "trivy"))
threshold := strings.ToUpper(strParam(node.Parameters, "severityThreshold", "HIGH"))
failOnFinding := boolParam(node.Parameters, "failOnFinding", true)
timeoutSec := intParam(node.Parameters, "timeoutSeconds", 600)
if timeoutSec < 30 {
timeoutSec = 30
}
if timeoutSec > 3600 {
timeoutSec = 3600
}
commitSHA, _ := trigger["commit"].(string)
ref := stripRefsHeads(strFirst(strFromAny(trigger["ref"]), a.Ref, "main"))
cloneDir, cleanup, err := cloneRepo(ctx, a, ref, commitSHA, time.Duration(timeoutSec)*time.Second)
if err != nil {
return nil, err
}
defer cleanup()
hardCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
defer cancel()
logger.Log(fmt.Sprintf("[sast:%s] running against %s @ %s", tool, a.Name, ref))
var (
findings []sastFinding
summary map[string]any
toolErr error
)
switch tool {
case "trivy":
findings, toolErr = runTrivy(hardCtx, cloneDir, threshold, logger)
case "semgrep":
findings, toolErr = runSemgrep(hardCtx, cloneDir, logger)
case "gitleaks":
findings, toolErr = runGitleaks(hardCtx, cloneDir, logger)
case "sonar":
summary, toolErr = runSonarCloud(hardCtx, node.Parameters, a, cloneDir, ref, commitSHA, logger)
case "custom":
findings, toolErr = runCustom(hardCtx, node.Parameters, cloneDir, logger)
default:
return nil, fmt.Errorf("sast: unknown tool %q", tool)
}
if toolErr != nil {
return nil, fmt.Errorf("sast (%s): %w", tool, toolErr)
}
// Counts by severity.
counts := map[string]int{}
for _, f := range findings {
counts[strings.ToUpper(f.Severity)]++
}
out := map[string]any{
"tool": tool,
"agent_id": agentID,
"agent_name": a.Name,
"ref": ref,
"commit": commitSHA,
"severityThreshold": threshold,
"counts": counts,
"finding_count": len(findings),
"finished_at": time.Now().UTC(),
}
if findings != nil {
out["findings"] = findings
}
if summary != nil {
// Sonar mode replaces per-finding output with a server-side
// quality-gate summary.
for k, v := range summary {
out[k] = v
}
}
logger.Log(fmt.Sprintf("[sast:%s] done — %d finding(s)", tool, len(findings)))
// Decide pass/fail.
failed := false
if failOnFinding {
if tool == "sonar" {
// Sonar passes/fails on its quality-gate verdict.
if status, _ := summary["qualityGate"].(string); strings.ToUpper(status) == "ERROR" {
failed = true
}
} else if exceedsThreshold(findings, threshold) {
failed = true
}
}
items := make([]models.Item, 0)
for _, in := range inputs {
for _, it := range in {
ci := copyItem(it)
ci["__sast"] = out
items = append(items, ci)
}
}
if len(items) == 0 {
items = append(items, models.Item{"__sast": out})
}
if failed {
return nil, fmt.Errorf("sast (%s) failed: severity threshold %s exceeded (%v)",
tool, threshold, counts)
}
return map[int][]models.Item{0: items}, nil
}
// sastFinding is the normalized shape we emit on output items.
type sastFinding struct {
Tool string `json:"tool"`
Severity string `json:"severity"`
RuleID string `json:"ruleId,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Message string `json:"message"`
}
// --- trivy ----------------------------------------------------------------
// trivy filesystem scan: vulns + secrets + IaC misconfig in one shot.
// We scan the host clone path by mounting it read-only into the trivy
// container. Output is JSON; we parse the few fields we render.
func runTrivy(ctx context.Context, dir, threshold string, logger engine.NodeLogger) ([]sastFinding, error) {
// Restrict scan to threshold + above so trivy doesn't dump 5000 LOW
// findings. Trivy understands a comma-separated severity list.
sev := severityChainAtOrAbove(threshold)
args := []string{
"run", "--rm",
"-v", dir + ":/src:ro",
"aquasec/trivy:latest",
"fs", "--quiet",
"--format", "json",
"--severity", sev,
"--scanners", "vuln,secret,misconfig",
"/src",
}
out, err := dockerRunCapture(ctx, args, logger)
if err != nil && len(out) == 0 {
return nil, err
}
return parseTrivy(out)
}
// trivyReport is the minimal shape of `trivy fs --format json`.
type trivyReport struct {
Results []struct {
Target string `json:"Target"`
Class string `json:"Class"`
Vulnerabilities []struct {
VulnerabilityID string `json:"VulnerabilityID"`
PkgName string `json:"PkgName"`
InstalledVersion string `json:"InstalledVersion"`
Severity string `json:"Severity"`
Title string `json:"Title"`
} `json:"Vulnerabilities,omitempty"`
Secrets []struct {
RuleID string `json:"RuleID"`
Severity string `json:"Severity"`
Title string `json:"Title"`
StartLine int `json:"StartLine"`
} `json:"Secrets,omitempty"`
Misconfigurations []struct {
ID string `json:"ID"`
Severity string `json:"Severity"`
Title string `json:"Title"`
} `json:"Misconfigurations,omitempty"`
} `json:"Results"`
}
func parseTrivy(raw []byte) ([]sastFinding, error) {
// Trivy may emit logs on stderr that bleed into combined output; find the
// first '{' to start parsing JSON.
if i := bytes.IndexByte(raw, '{'); i > 0 {
raw = raw[i:]
}
var r trivyReport
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("parse trivy json: %w", err)
}
var findings []sastFinding
for _, res := range r.Results {
for _, v := range res.Vulnerabilities {
findings = append(findings, sastFinding{
Tool: "trivy",
Severity: v.Severity,
RuleID: v.VulnerabilityID,
File: res.Target,
Message: fmt.Sprintf("%s in %s@%s — %s",
v.VulnerabilityID, v.PkgName, v.InstalledVersion, v.Title),
})
}
for _, s := range res.Secrets {
findings = append(findings, sastFinding{
Tool: "trivy",
Severity: s.Severity,
RuleID: s.RuleID,
File: res.Target,
Line: s.StartLine,
Message: s.Title,
})
}
for _, m := range res.Misconfigurations {
findings = append(findings, sastFinding{
Tool: "trivy",
Severity: m.Severity,
RuleID: m.ID,
File: res.Target,
Message: m.Title,
})
}
}
return findings, nil
}
// --- semgrep --------------------------------------------------------------
func runSemgrep(ctx context.Context, dir string, logger engine.NodeLogger) ([]sastFinding, error) {
args := []string{
"run", "--rm",
"-v", dir + ":/src:ro",
"-w", "/src",
"returntocorp/semgrep:latest",
"semgrep", "scan",
"--config", "auto", // pulls Semgrep's curated registry rules
"--json", "--quiet",
}
out, err := dockerRunCapture(ctx, args, logger)
if err != nil && len(out) == 0 {
return nil, err
}
return parseSemgrep(out)
}
type semgrepReport struct {
Results []struct {
CheckID string `json:"check_id"`
Path string `json:"path"`
Start struct {
Line int `json:"line"`
} `json:"start"`
Extra struct {
Severity string `json:"severity"`
Message string `json:"message"`
} `json:"extra"`
} `json:"results"`
}
func parseSemgrep(raw []byte) ([]sastFinding, error) {
if i := bytes.IndexByte(raw, '{'); i > 0 {
raw = raw[i:]
}
var r semgrepReport
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("parse semgrep json: %w", err)
}
out := make([]sastFinding, 0, len(r.Results))
for _, x := range r.Results {
out = append(out, sastFinding{
Tool: "semgrep",
Severity: normalizeSemgrepSev(x.Extra.Severity),
RuleID: x.CheckID,
File: x.Path,
Line: x.Start.Line,
Message: x.Extra.Message,
})
}
return out, nil
}
// Semgrep uses ERROR/WARNING/INFO; map to our LOW/MEDIUM/HIGH/CRITICAL.
func normalizeSemgrepSev(s string) string {
switch strings.ToUpper(s) {
case "ERROR":
return "HIGH"
case "WARNING":
return "MEDIUM"
case "INFO":
return "LOW"
}
return strings.ToUpper(s)
}
// --- gitleaks -------------------------------------------------------------
func runGitleaks(ctx context.Context, dir string, logger engine.NodeLogger) ([]sastFinding, error) {
args := []string{
"run", "--rm",
"-v", dir + ":/src:ro",
"zricethezav/gitleaks:latest",
"detect", "--source=/src",
"--no-git", // we're scanning the working tree, not git history
"--report-format=json", "--report-path=/dev/stdout",
"--no-banner",
}
out, err := dockerRunCapture(ctx, args, logger)
if err != nil && len(out) == 0 {
return nil, err
}
return parseGitleaks(out)
}
type gitleaksFinding struct {
RuleID string `json:"RuleID"`
Description string `json:"Description"`
File string `json:"File"`
StartLine int `json:"StartLine"`
Match string `json:"Match"`
}
func parseGitleaks(raw []byte) ([]sastFinding, error) {
// gitleaks --report-path=/dev/stdout emits a JSON array.
if i := bytes.IndexByte(raw, '['); i > 0 {
raw = raw[i:]
}
var arr []gitleaksFinding
if err := json.Unmarshal(raw, &arr); err != nil {
// gitleaks prints "no leaks found" sometimes; treat parse failure
// without a leading '[' as zero findings.
return nil, nil
}
out := make([]sastFinding, 0, len(arr))
for _, g := range arr {
out = append(out, sastFinding{
Tool: "gitleaks",
Severity: "HIGH", // any leaked secret is high severity
RuleID: g.RuleID,
File: g.File,
Line: g.StartLine,
Message: g.Description,
})
}
return out, nil
}
// --- sonar (SonarCloud) ---------------------------------------------------
// runSonarCloud uploads the workspace to SonarCloud via the official scanner
// container and polls the v2 quality-gate API for the verdict. Returns a
// summary that includes the quality gate status (OK | WARN | ERROR), the
// dashboard URL, and the underlying analysis ID for traceability.
func runSonarCloud(ctx context.Context, p map[string]any, a *storage.Agent, cloneDir, ref, commit string, logger engine.NodeLogger) (map[string]any, error) {
host := strParam(p, "sonarHost", "https://sonarcloud.io")
org := strings.TrimSpace(strParam(p, "organization", ""))
projectKey := strings.TrimSpace(strParam(p, "projectKey", ""))
token := strings.TrimSpace(strParam(p, "sonarToken", ""))
branch := strFirst(strParam(p, "branchName", ""), ref, "main")
if org == "" || projectKey == "" || token == "" {
return nil, errors.New("sonar requires organization, projectKey, and sonarToken")
}
args := []string{
"run", "--rm",
"-e", "SONAR_HOST_URL=" + host,
"-e", "SONAR_TOKEN=" + token,
"-v", cloneDir + ":/usr/src:ro",
"-w", "/usr/src",
"sonarsource/sonar-scanner-cli:latest",
"-Dsonar.organization=" + org,
"-Dsonar.projectKey=" + projectKey,
"-Dsonar.sources=.",
"-Dsonar.branch.name=" + branch,
}
if commit != "" {
args = append(args, "-Dsonar.scm.revision="+commit)
}
if _, err := dockerRunCapture(ctx, args, logger); err != nil {
return nil, fmt.Errorf("sonar-scanner: %w", err)
}
// Poll for the quality gate verdict — analysis is async server-side.
logger.Log("[sast:sonar] waiting for quality gate verdict…")
gate, err := pollSonarGate(ctx, host, org, projectKey, branch, token)
if err != nil {
return nil, fmt.Errorf("sonar quality gate: %w", err)
}
logger.Log(fmt.Sprintf("[sast:sonar] quality gate: %s", gate))
dashboardURL := fmt.Sprintf("%s/project/overview?id=%s",
strings.TrimRight(host, "/"), url.QueryEscape(projectKey))
return map[string]any{
"qualityGate": gate,
"dashboardUrl": dashboardURL,
"projectKey": projectKey,
"branchName": branch,
}, nil
}
// pollSonarGate polls /api/qualitygates/project_status until SonarCloud
// returns a non-NONE / non-PENDING verdict. Bounded by the parent ctx
// (the executor's hard timeout).
func pollSonarGate(ctx context.Context, host, org, projectKey, branch, token string) (string, error) {
endpoint := fmt.Sprintf("%s/api/qualitygates/project_status?projectKey=%s&branch=%s",
strings.TrimRight(host, "/"),
url.QueryEscape(projectKey),
url.QueryEscape(branch))
deadline := time.NewTicker(5 * time.Second)
defer deadline.Stop()
httpc := &http.Client{Timeout: 15 * time.Second}
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
req.SetBasicAuth(token, "") // SonarCloud convention
req.Header.Set("Accept", "application/json")
resp, err := httpc.Do(req)
if err != nil {
return "", err
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d: %s", resp.StatusCode, oneLineSummary(string(body)))
}
var r struct {
ProjectStatus struct {
Status string `json:"status"` // OK | WARN | ERROR | NONE
} `json:"projectStatus"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", err
}
switch strings.ToUpper(r.ProjectStatus.Status) {
case "OK", "WARN", "ERROR":
return r.ProjectStatus.Status, nil
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-deadline.C:
}
}
}
// --- custom ---------------------------------------------------------------
func runCustom(ctx context.Context, p map[string]any, dir string, logger engine.NodeLogger) ([]sastFinding, error) {
image := strings.TrimSpace(strParam(p, "image", ""))
command := strings.TrimSpace(strParam(p, "command", ""))
if image == "" || command == "" {
return nil, errors.New("custom tool requires image and command")
}
args := []string{
"run", "--rm",
"-v", dir + ":/src:ro",
"-w", "/src",
image,
"/bin/sh", "-c", command,
}
out, err := dockerRunCapture(ctx, args, logger)
if err != nil {
return nil, fmt.Errorf("custom scanner exit: %w (last: %s)", err, oneLineSummary(string(out)))
}
// We don't parse arbitrary tool output — just emit a single finding
// of unknown severity carrying the tail. Users wiring a real tool can
// switch to `tool: trivy` etc., or post-process via downstream nodes.
return []sastFinding{{
Tool: "custom",
Severity: "UNKNOWN",
Message: lastLines(string(out), 20),
}}, nil
}
// --- helpers --------------------------------------------------------------
// dockerRunCapture spawns a `docker run …` subprocess, streams stdout/stderr
// through the NodeLogger so the UI sees live output, and returns the full
// stdout as bytes for downstream JSON parsing.
func dockerRunCapture(ctx context.Context, args []string, logger engine.NodeLogger) ([]byte, error) {
logger.Log("$ docker " + strings.Join(args, " "))
cmd := exec.CommandContext(ctx, "docker", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
// Capture stdout (the JSON / report) into a buffer; stream stderr to
// the logger so users see it live.
var buf bytes.Buffer
doneOut := make(chan error, 1)
go func() {
_, e := io.Copy(&buf, io.TeeReader(stdout, &lineLogger{logger: logger, prefix: ""}))
doneOut <- e
}()
go func() {
_, _ = io.Copy(&lineLogger{logger: logger, prefix: ""}, stderr)
}()
<-doneOut
werr := cmd.Wait()
return buf.Bytes(), werr
}
// lineLogger is an io.Writer that splits incoming bytes on '\n' and forwards
// each non-empty line to a NodeLogger. Used so a docker subprocess's output
// streams live into the SSE feed.
type lineLogger struct {
logger engine.NodeLogger
prefix string
buf []byte
}
func (l *lineLogger) Write(p []byte) (int, error) {
l.buf = append(l.buf, p...)
for {
i := bytes.IndexByte(l.buf, '\n')
if i < 0 {
return len(p), nil
}
line := strings.TrimRight(string(l.buf[:i]), "\r")
if line != "" {
l.logger.Log(l.prefix + line)
}
l.buf = l.buf[i+1:]
}
}
// severityChainAtOrAbove returns a comma-list of severities at or above
// the given threshold, in trivy's expected casing.
func severityChainAtOrAbove(threshold string) string {
chain := []string{"LOW", "MEDIUM", "HIGH", "CRITICAL"}
t := strings.ToUpper(threshold)
for i, s := range chain {
if s == t {
return strings.Join(chain[i:], ",")
}
}
return "HIGH,CRITICAL"
}
// exceedsThreshold returns true if any finding's severity is at or above
// the threshold. Used to gate the run when failOnFinding is true.
func exceedsThreshold(findings []sastFinding, threshold string) bool {
rank := map[string]int{"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
tr := rank[strings.ToUpper(threshold)]
if tr == 0 {
tr = 3 // default HIGH
}
for _, f := range findings {
if rank[strings.ToUpper(f.Severity)] >= tr {
return true
}
}
return false
}
+297
View File
@@ -438,6 +438,9 @@ function NodeRow(props: {
const buildSummary = extractBuildSummary(node.name, statusOutputs);
const triggerSummary = extractTriggerSummary(node.name, statusOutputs);
const sastSummary = extractScanSummary(node.name, statusOutputs, "__sast");
const imageScanSummary = extractScanSummary(node.name, statusOutputs, "__imageScan");
const pushSummary = extractPushSummary(node.name, statusOutputs);
return (
<div className="rounded-lg border bg-muted/10">
@@ -510,6 +513,41 @@ function NodeRow(props: {
</div>
)}
{sastSummary && (
<ScanResults summary={sastSummary} kind="sast" />
)}
{imageScanSummary && (
<ScanResults summary={imageScanSummary} kind="imageScan" />
)}
{pushSummary && (
<div className="border-t px-4 py-3 font-mono text-[11px] leading-6">
<div>
<span className="text-muted-foreground">src: </span>
<span className="break-all">{pushSummary.src}</span>
</div>
{pushSummary.copies.map((c, i) => (
<div key={i}>
<span className="text-muted-foreground">
{c.name || c.registry}:{" "}
</span>
<span className="break-all">{c.imageRef}</span>
{c.error ? (
<span className="ml-1 text-rose-500"> {c.error}</span>
) : c.digest ? (
<span className="ml-1 text-emerald-600 dark:text-emerald-500">
{" "}
{" "}
<span className="text-muted-foreground">
{c.digest.slice(0, 19)}
</span>
</span>
) : null}
</div>
))}
</div>
)}
{/* Build log disclosure — visible for all node types that emitted lines */}
{(liveLog?.length || isTerminal) && (
<LogDisclosure
@@ -736,3 +774,262 @@ function extractTriggerSummary(
ref: typeof item.ref === "string" ? item.ref : undefined,
};
}
// --- scan / push helpers -------------------------------------------------
type ScanFinding = {
tool?: string;
severity?: string;
ruleId?: string;
file?: string;
line?: number;
message?: string;
};
type ScanSummary = {
tool?: string;
imageRef?: string;
scanRef?: string;
qualityGate?: string;
dashboardUrl?: string;
threshold?: string;
counts: Record<string, number>;
findingCount: number;
findings: ScanFinding[];
};
function extractScanSummary(
nodeName: string,
outputs: Record<string, unknown> | undefined,
key: "__sast" | "__imageScan"
): ScanSummary | null {
if (!outputs) return null;
const port = ((outputs[nodeName] as Record<string, unknown>) || {})["0"];
if (!Array.isArray(port) || port.length === 0) return null;
const item = port[0] as Record<string, unknown>;
const blob = item[key] as Record<string, unknown> | undefined;
if (!blob) return null;
const counts: Record<string, number> = {};
if (blob.counts && typeof blob.counts === "object") {
for (const [k, v] of Object.entries(blob.counts as Record<string, unknown>)) {
if (typeof v === "number") counts[k.toUpperCase()] = v;
}
}
const findings = Array.isArray(blob.findings) ? (blob.findings as ScanFinding[]) : [];
return {
tool: typeof blob.tool === "string" ? blob.tool : undefined,
imageRef: typeof blob.imageRef === "string" ? blob.imageRef : undefined,
scanRef: typeof blob.scanRef === "string" ? blob.scanRef : undefined,
qualityGate: typeof blob.qualityGate === "string" ? blob.qualityGate : undefined,
dashboardUrl: typeof blob.dashboardUrl === "string" ? blob.dashboardUrl : undefined,
threshold:
typeof blob.severityThreshold === "string"
? (blob.severityThreshold as string)
: undefined,
counts,
findingCount:
typeof blob.finding_count === "number"
? (blob.finding_count as number)
: findings.length,
findings,
};
}
type PushSummary = {
src: string;
copies: { name?: string; registry?: string; imageRef: string; digest?: string; error?: string }[];
};
function extractPushSummary(
nodeName: string,
outputs?: Record<string, unknown>
): PushSummary | null {
if (!outputs) return null;
const port = ((outputs[nodeName] as Record<string, unknown>) || {})["0"];
if (!Array.isArray(port) || port.length === 0) return null;
const item = port[0] as Record<string, unknown>;
const blob = item.__push as Record<string, unknown> | undefined;
if (!blob) return null;
const copies = Array.isArray(blob.copies)
? (blob.copies as Record<string, unknown>[]).map((c) => ({
name: typeof c.name === "string" ? c.name : undefined,
registry: typeof c.registry === "string" ? c.registry : undefined,
imageRef: typeof c.imageRef === "string" ? c.imageRef : "",
digest: typeof c.digest === "string" ? c.digest : undefined,
error: typeof c.error === "string" && c.error ? c.error : undefined,
}))
: [];
// Single-target legacy shape — promote dst to a one-entry copies array.
if (copies.length === 0 && typeof blob.dst === "string") {
copies.push({
name: undefined,
registry: undefined,
imageRef: blob.dst as string,
digest: typeof blob.digest === "string" ? (blob.digest as string) : undefined,
error: undefined,
});
}
return {
src: typeof blob.src === "string" ? (blob.src as string) : "",
copies,
};
}
const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"] as const;
function severityClass(sev: string): string {
switch (sev.toUpperCase()) {
case "CRITICAL":
return "bg-rose-600/20 text-rose-700 dark:text-rose-400 border-rose-600/40";
case "HIGH":
return "bg-orange-500/20 text-orange-700 dark:text-orange-400 border-orange-500/40";
case "MEDIUM":
return "bg-amber-500/20 text-amber-700 dark:text-amber-400 border-amber-500/40";
case "LOW":
return "bg-sky-500/20 text-sky-700 dark:text-sky-400 border-sky-500/40";
default:
return "bg-muted text-muted-foreground border-border";
}
}
function ScanResults({
summary,
kind,
}: {
summary: ScanSummary;
kind: "sast" | "imageScan";
}) {
const [open, setOpen] = useState(false);
const total = summary.findingCount;
const showFindings = summary.findings && summary.findings.length > 0;
const isSonar = summary.tool === "sonar";
const headerLabel = kind === "sast" ? "SAST" : "Image scan";
return (
<div className="border-t">
<div className="flex items-center justify-between gap-3 px-4 py-3 text-xs">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium uppercase tracking-wider text-muted-foreground">
{headerLabel}
</span>
{summary.tool && (
<span className="rounded-md border bg-muted/40 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider">
{summary.tool}
</span>
)}
{summary.threshold && (
<span className="text-[10px] text-muted-foreground">
threshold {summary.threshold}
</span>
)}
{/* Severity counts pills */}
{SEVERITY_ORDER.map((sev) => {
const n = summary.counts[sev] || 0;
if (n === 0) return null;
return (
<span
key={sev}
className={
"rounded-md border px-2 py-0.5 text-[10px] font-medium " +
severityClass(sev)
}
>
{sev.toLowerCase()} {n}
</span>
);
})}
{total === 0 && !isSonar && (
<span className="rounded-md border border-emerald-500/40 bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-400">
clean
</span>
)}
{isSonar && summary.qualityGate && (
<span
className={
"rounded-md border px-2 py-0.5 text-[10px] font-medium " +
(summary.qualityGate === "OK"
? "bg-emerald-500/15 text-emerald-700 border-emerald-500/40 dark:text-emerald-400"
: summary.qualityGate === "WARN"
? "bg-amber-500/20 text-amber-700 border-amber-500/40 dark:text-amber-400"
: "bg-rose-500/20 text-rose-700 border-rose-500/40 dark:text-rose-400")
}
>
gate {summary.qualityGate}
</span>
)}
</div>
{showFindings && (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 rounded-md border bg-background px-2 py-0.5 text-[10px] hover:bg-accent"
>
{open ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
{total} {total === 1 ? "finding" : "findings"}
</button>
)}
{summary.dashboardUrl && (
<a
href={summary.dashboardUrl}
target="_blank"
rel="noreferrer"
className="text-[10px] text-primary underline-offset-4 hover:underline"
>
open dashboard
</a>
)}
</div>
{open && showFindings && (
<div className="border-t bg-muted/10 px-4 py-2">
<div className="max-h-72 overflow-auto">
<table className="w-full text-left text-[11px]">
<thead className="text-muted-foreground">
<tr>
<th className="py-1 pr-2">severity</th>
<th className="py-1 pr-2">rule</th>
<th className="py-1 pr-2">where</th>
<th className="py-1">message</th>
</tr>
</thead>
<tbody>
{summary.findings.slice(0, 100).map((f, i) => (
<tr key={i} className="border-t border-border/50">
<td className="py-1 pr-2 align-top">
<span
className={
"rounded-md border px-1.5 py-0.5 text-[10px] font-medium " +
severityClass(f.severity ?? "UNKNOWN")
}
>
{(f.severity ?? "?").toLowerCase()}
</span>
</td>
<td className="py-1 pr-2 align-top font-mono text-[10px]">
{f.ruleId ?? ""}
</td>
<td className="py-1 pr-2 align-top font-mono text-[10px] text-muted-foreground">
{f.file ? `${f.file}${f.line ? ":" + f.line : ""}` : ""}
</td>
<td className="py-1 align-top">{f.message ?? ""}</td>
</tr>
))}
</tbody>
</table>
{summary.findings.length > 100 && (
<div className="mt-1 text-[10px] text-muted-foreground">
+ {summary.findings.length - 100} more (truncated)
</div>
)}
</div>
</div>
)}
</div>
);
}
+388
View File
@@ -31,6 +31,10 @@ export function NodeForm({ node, onChange }: NodeFormProps) {
return <PolicyForm node={node} onChange={onChange} />;
case "flow-nodes-base.waitForApproval":
return <ApprovalForm node={node} onChange={onChange} />;
case "flow-nodes-base.sast":
return <SastForm node={node} onChange={onChange} />;
case "flow-nodes-base.imageScan":
return <ImageScanForm node={node} onChange={onChange} />;
case "flow-nodes-base.push":
return <PushForm node={node} onChange={onChange} />;
case "flow-nodes-base.deploy":
@@ -50,6 +54,8 @@ export function hasTypedForm(type: string): boolean {
return [
"flow-nodes-base.trigger",
"flow-nodes-base.build",
"flow-nodes-base.sast",
"flow-nodes-base.imageScan",
"flow-nodes-base.push",
"flow-nodes-base.test",
"flow-nodes-base.eval",
@@ -483,6 +489,388 @@ function ApprovalForm({ node, onChange }: NodeFormProps) {
);
}
function SastForm({ node, onChange }: NodeFormProps) {
const tool = getString(node, "tool", "trivy");
const threshold = getString(node, "severityThreshold", "HIGH");
const failOnFinding = getBool(node, "failOnFinding", true);
const timeout = getNumber(node, "timeoutSeconds", 600);
// sonar
const sonarHost = getString(node, "sonarHost", "https://sonarcloud.io");
const organization = getString(node, "organization", "");
const projectKey = getString(node, "projectKey", "");
const sonarToken = getString(node, "sonarToken", "");
const branchName = getString(node, "branchName", "");
// custom
const image = getString(node, "image", "");
const command = getString(node, "command", "");
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Tool</Label>
<select
value={tool}
onChange={(e) => onChange(setParam(node, "tool", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="trivy">Trivy vulns + secrets + IaC misconfig</option>
<option value="semgrep">Semgrep code-flow / taint analysis</option>
<option value="gitleaks">Gitleaks leaked secrets</option>
<option value="sonar">SonarCloud code quality + quality gate</option>
<option value="custom">Custom your container, your command</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Fail-at severity</Label>
<select
value={threshold}
onChange={(e) =>
onChange(setParam(node, "severityThreshold", e.target.value))
}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="LOW">LOW (everything)</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HIGH">HIGH (default)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="sast-timeout">Timeout (s)</Label>
<Input
id="sast-timeout"
type="number"
min={30}
max={3600}
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="sast-fail"
type="checkbox"
checked={failOnFinding}
onChange={(e) =>
onChange(setParam(node, "failOnFinding", e.target.checked))
}
className="size-3.5"
/>
<Label htmlFor="sast-fail" className="text-[11px]">
Fail the run when findings exceed threshold (default on)
</Label>
</div>
{tool === "sonar" && (
<div className="rounded-md border bg-muted/20 p-3 space-y-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
SonarCloud
</div>
<div className="space-y-1.5">
<Label htmlFor="sonar-host">Host</Label>
<Input
id="sonar-host"
value={sonarHost}
onChange={(e) =>
onChange(setParam(node, "sonarHost", e.target.value))
}
placeholder="https://sonarcloud.io"
className="font-mono text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label htmlFor="sonar-org">Organization</Label>
<Input
id="sonar-org"
value={organization}
onChange={(e) =>
onChange(setParam(node, "organization", e.target.value))
}
placeholder="my-org"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sonar-key">Project key</Label>
<Input
id="sonar-key"
value={projectKey}
onChange={(e) =>
onChange(setParam(node, "projectKey", e.target.value))
}
placeholder="my-org_my-agent"
className="font-mono text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="sonar-token">Token</Label>
<Input
id="sonar-token"
type="password"
value={sonarToken}
onChange={(e) =>
onChange(setParam(node, "sonarToken", e.target.value))
}
placeholder="SONAR_TOKEN (User → My Account → Security)"
autoComplete="new-password"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sonar-branch">Branch (optional)</Label>
<Input
id="sonar-branch"
value={branchName}
onChange={(e) =>
onChange(setParam(node, "branchName", e.target.value))
}
placeholder="(uses agent ref by default)"
className="font-mono text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Quality-gate failure flips the run to <code>failed</code>. We
also link the dashboard URL on the run page.
</p>
</div>
)}
{tool === "custom" && (
<div className="rounded-md border bg-muted/20 p-3 space-y-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Custom scanner
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-img">Container image</Label>
<Input
id="cust-img"
value={image}
onChange={(e) =>
onChange(setParam(node, "image", e.target.value))
}
placeholder="ghcr.io/owner/scanner:latest"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-cmd">Command (runs in /src)</Label>
<Textarea
id="cust-cmd"
rows={3}
value={command}
onChange={(e) =>
onChange(setParam(node, "command", e.target.value))
}
spellCheck={false}
placeholder="my-scanner --src /src --json"
className="font-mono text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Repo is mounted at <code>/src</code> read-only. Non-zero exit
fails the node.
</p>
</div>
)}
{tool !== "sonar" && tool !== "custom" && (
<p className="text-[11px] text-muted-foreground">
Sane defaults no extra config needed. Findings list ends up in{" "}
<code className="font-mono">__sast.findings</code> and per-finding
lines stream into the build log.
</p>
)}
</div>
);
}
function ImageScanForm({ node, onChange }: NodeFormProps) {
const tool = getString(node, "tool", "trivy");
const threshold = getString(node, "severityThreshold", "HIGH");
const failOnFinding = getBool(node, "failOnFinding", true);
const timeout = getNumber(node, "timeoutSeconds", 600);
const insecure = getBool(node, "insecure", true);
const imageRef = getString(node, "imageRef", "");
const registryUsername = getString(node, "registryUsername", "");
const registryPassword = getString(node, "registryPassword", "");
const image = getString(node, "image", "");
const command = getString(node, "command", "");
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Tool</Label>
<select
value={tool}
onChange={(e) => onChange(setParam(node, "tool", e.target.value))}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="trivy">Trivy CVEs + secrets in the image</option>
<option value="grype">Grype Anchore CVE scanner</option>
<option value="custom">Custom your container, your command</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="is-ref">Image ref (optional)</Label>
<Input
id="is-ref"
value={imageRef}
onChange={(e) => onChange(setParam(node, "imageRef", e.target.value))}
placeholder="registry:5000/owner/agent:sha (defaults to upstream Build)"
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Leave blank to use upstream{" "}
<code className="font-mono">__build.image</code>.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Fail-at severity</Label>
<select
value={threshold}
onChange={(e) =>
onChange(setParam(node, "severityThreshold", e.target.value))
}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm"
>
<option value="LOW">LOW</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HIGH">HIGH (default)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="is-timeout">Timeout (s)</Label>
<Input
id="is-timeout"
type="number"
min={30}
max={3600}
value={timeout}
onChange={(e) =>
onChange(setParam(node, "timeoutSeconds", Number(e.target.value)))
}
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="is-fail"
type="checkbox"
checked={failOnFinding}
onChange={(e) =>
onChange(setParam(node, "failOnFinding", e.target.checked))
}
className="size-3.5"
/>
<Label htmlFor="is-fail" className="text-[11px]">
Fail run when findings exceed threshold
</Label>
</div>
<div className="flex items-center gap-2">
<input
id="is-insecure"
type="checkbox"
checked={insecure}
onChange={(e) =>
onChange(setParam(node, "insecure", e.target.checked))
}
className="size-3.5"
/>
<Label htmlFor="is-insecure" className="text-[11px]">
Source registry allows HTTP (default; the local{" "}
<code className="font-mono">registry:5000</code> is plain HTTP)
</Label>
</div>
{tool !== "custom" && (
<div className="rounded-md border bg-muted/20 p-3 space-y-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Registry auth (optional only needed for private sources)
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label htmlFor="is-user">Username</Label>
<Input
id="is-user"
value={registryUsername}
onChange={(e) =>
onChange(setParam(node, "registryUsername", e.target.value))
}
placeholder="(empty = anonymous)"
autoComplete="off"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="is-pass">Password / token</Label>
<Input
id="is-pass"
type="password"
value={registryPassword}
onChange={(e) =>
onChange(setParam(node, "registryPassword", e.target.value))
}
placeholder="ghp_… or registry password"
autoComplete="new-password"
className="font-mono text-xs"
/>
</div>
</div>
</div>
)}
{tool === "custom" && (
<div className="rounded-md border bg-muted/20 p-3 space-y-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Custom scanner
</div>
<div className="space-y-1.5">
<Label htmlFor="is-cust-img">Container image</Label>
<Input
id="is-cust-img"
value={image}
onChange={(e) => onChange(setParam(node, "image", e.target.value))}
placeholder="ghcr.io/owner/scanner:latest"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="is-cust-cmd">
Command (image ref exported as <code>$IMAGE_REF</code>)
</Label>
<Textarea
id="is-cust-cmd"
rows={3}
value={command}
onChange={(e) =>
onChange(setParam(node, "command", e.target.value))
}
spellCheck={false}
placeholder='trivy image --quiet "$IMAGE_REF"'
className="font-mono text-xs"
/>
</div>
</div>
)}
</div>
);
}
type PushTarget = {
name?: string;
registry?: string;
+50
View File
@@ -15,6 +15,8 @@ import {
TestTube2,
Gauge,
ShieldCheck,
ShieldAlert,
Container,
Pause,
Rocket,
ArrowUpFromLine,
@@ -133,6 +135,54 @@ export const CATALOG: CatalogEntry[] = [
},
group: "gate",
},
{
type: "flow-nodes-base.imageScan",
label: "Image Scan",
description:
"Scan the BUILT container image for CVEs, secrets, and base-image vulns. Pulls from the local registry and gates on severity.",
icon: Container,
color: "bg-fuchsia-600",
outputs: 1,
defaults: {
tool: "trivy",
severityThreshold: "HIGH",
failOnFinding: true,
timeoutSeconds: 600,
insecure: true,
registryUsername: "",
registryPassword: "",
// imageRef left empty → defaults to upstream __build.image
// custom-only:
image: "",
command: "",
},
group: "gate",
},
{
type: "flow-nodes-base.sast",
label: "SAST",
description:
"Static analysis: scan the agent repo for vulnerabilities, secrets, and quality issues. Pluggable tool — Trivy / Semgrep / Gitleaks / SonarCloud / custom.",
icon: ShieldAlert,
color: "bg-rose-500",
outputs: 1,
defaults: {
tool: "trivy",
severityThreshold: "HIGH",
failOnFinding: true,
timeoutSeconds: 600,
// sonar-only:
sonarHost: "https://sonarcloud.io",
organization: "",
projectKey: "",
sonarToken: "",
branchName: "",
// custom-only:
image: "",
command: "",
},
group: "gate",
},
{
type: "flow-nodes-base.push",
label: "Push",