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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ func init() {
commands.RegisterSastCommands(apiCmd, getAPIClient)
commands.RegisterScaCommands(apiCmd, getAPIClient)
commands.RegisterSecretsCommands(apiCmd, getAPIClient)

// Hand-written workflow — not generated from OpenAPI. Routes through
// scpm's /scpm/dependencies/analyze. Wired at top level (not under
// apiCmd) so `nullify deps analyze` reads naturally in CI scripts.
commands.RegisterDepsAnalyzeCommand(rootCmd, getAPIClient)
}

func setupLogger(ctx context.Context) context.Context {
Expand Down
6 changes: 5 additions & 1 deletion cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import (
"os"

"github.com/nullify-platform/cli/cmd/cli/cmd"
"github.com/nullify-platform/cli/internal/commands"
)

func main() {
// commands.ExitCodeFromError maps the deps-analyze workflow's
// exit-coded errors (severity gate: 10/20/30, invalid: 2, transient:
// 1) onto the process exit code; any other error falls back to 1.
if err := cmd.Execute(); err != nil {
os.Exit(1)
os.Exit(commands.ExitCodeFromError(err))
}
}
85 changes: 85 additions & 0 deletions internal/ci/aws_codebuild.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package ci

import (
"context"
"net/http"
"os"
"strings"
)

// AWSCodeBuild — https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html
//
// Key envs:
//
// CODEBUILD_BUILD_ID signature
// CODEBUILD_RESOLVED_SOURCE_VERSION head
// CODEBUILD_WEBHOOK_BASE_REF base (webhook-triggered PRs)
// CODEBUILD_SOURCE_REPO_URL https://github.com/org/repo.git
type AWSCodeBuild struct{}

func NewAWSCodeBuild() Provider { return &AWSCodeBuild{} }

func (a *AWSCodeBuild) Platform() Platform { return PlatformAWSCodeBuild }

func (a *AWSCodeBuild) Detect() bool { return os.Getenv("CODEBUILD_BUILD_ID") != "" }

func (a *AWSCodeBuild) BaseRef(ctx context.Context, repoPath string) (string, error) {
// CODEBUILD_WEBHOOK_BASE_REF is "refs/heads/main" shape — strip the
// prefix + resolve.
if v := os.Getenv("CODEBUILD_WEBHOOK_BASE_REF"); v != "" {
return resolveRef(ctx, repoPath, "origin/"+strings.TrimPrefix(v, "refs/heads/"))
}
return resolveRef(ctx, repoPath, "origin/HEAD")
}

func (a *AWSCodeBuild) HeadRef(ctx context.Context, repoPath string) (string, error) {
if v := os.Getenv("CODEBUILD_RESOLVED_SOURCE_VERSION"); v != "" {
return v, nil
}
return resolveRef(ctx, repoPath, "HEAD")
}

func (a *AWSCodeBuild) PRNumber() (int, bool) {
// CODEBUILD_WEBHOOK_TRIGGER sometimes has "pr/123" shape.
trigger := os.Getenv("CODEBUILD_WEBHOOK_TRIGGER")
if !strings.HasPrefix(trigger, "pr/") {
return 0, false
}
n := 0
for _, c := range trigger[3:] {
if c < '0' || c > '9' {
return 0, false
}
n = n*10 + int(c-'0')
}
return n, true
}

func (a *AWSCodeBuild) RepoSlug() (string, string, bool) {
repoURL := os.Getenv("CODEBUILD_SOURCE_REPO_URL")
if repoURL == "" {
return "", "", false
}
// Strip trailing .git. Handle both HTTPS
// ("https://github.com/owner/repo.git") and SSH
// ("git@github.com:owner/repo.git") forms: in the SSH shape the
// owner is separated from the host by ':', so normalise that to '/'
// before taking the final "owner/name" segments.
trimmed := strings.TrimSuffix(repoURL, ".git")
trimmed = strings.ReplaceAll(trimmed, ":", "/")
parts := strings.Split(trimmed, "/")
if len(parts) < 2 {
return "", "", false
}
return parts[len(parts)-2], parts[len(parts)-1], true
}

func (a *AWSCodeBuild) EnrichHeader(h http.Header) {
if v := os.Getenv("CODEBUILD_BUILD_ID"); v != "" {
h.Set("X-Nullify-CI-Run-ID", v)
}
if v := os.Getenv("CODEBUILD_RESOLVED_SOURCE_VERSION"); v != "" {
h.Set("X-Nullify-CI-Commit", v)
}
h.Set("X-Nullify-CI-Provider", a.Platform().String())
}
73 changes: 73 additions & 0 deletions internal/ci/azure_devops.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package ci

import (
"context"
"net/http"
"os"
"strconv"
)

// AzureDevOps — https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables
//
// Key envs:
//
// TF_BUILD=True signature
// BUILD_SOURCEVERSION head commit
// SYSTEM_PULLREQUEST_TARGETBRANCHNAME target branch (PR builds)
// SYSTEM_PULLREQUEST_PULLREQUESTNUMBER PR number
// BUILD_REPOSITORY_NAME repo name (no owner)
// BUILD_BUILDID run id
type AzureDevOps struct{}

func NewAzureDevOps() Provider { return &AzureDevOps{} }

func (a *AzureDevOps) Platform() Platform { return PlatformAzureDevOps }

func (a *AzureDevOps) Detect() bool { return os.Getenv("TF_BUILD") == "True" }

func (a *AzureDevOps) BaseRef(ctx context.Context, repoPath string) (string, error) {
if v := os.Getenv("SYSTEM_PULLREQUEST_TARGETBRANCHNAME"); v != "" {
return resolveRef(ctx, repoPath, "origin/"+v)
}
return resolveRef(ctx, repoPath, "HEAD^")
}

func (a *AzureDevOps) HeadRef(ctx context.Context, repoPath string) (string, error) {
if v := os.Getenv("BUILD_SOURCEVERSION"); v != "" {
return v, nil
}
return resolveRef(ctx, repoPath, "HEAD")
}

func (a *AzureDevOps) PRNumber() (int, bool) {
v := os.Getenv("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")
if v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return n, true
}

func (a *AzureDevOps) RepoSlug() (string, string, bool) {
// Azure uses Organization/Project/Repo — not a two-part slug.
// Collapse Organization as the "owner" field.
owner := os.Getenv("SYSTEM_COLLECTIONURI")
name := os.Getenv("BUILD_REPOSITORY_NAME")
if name == "" {
return "", "", false
}
return owner, name, true
}

func (a *AzureDevOps) EnrichHeader(h http.Header) {
if v := os.Getenv("BUILD_BUILDID"); v != "" {
h.Set("X-Nullify-CI-Run-ID", v)
}
if v := os.Getenv("BUILD_SOURCEVERSION"); v != "" {
h.Set("X-Nullify-CI-Commit", v)
}
h.Set("X-Nullify-CI-Provider", a.Platform().String())
}
72 changes: 72 additions & 0 deletions internal/ci/bitbucket_pipelines.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package ci

import (
"context"
"net/http"
"os"
"strconv"
)

// BitbucketPipelines — https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/
//
// Key envs:
//
// BITBUCKET_BUILD_NUMBER signature
// BITBUCKET_COMMIT head
// BITBUCKET_PR_DESTINATION_BRANCH target branch (PR builds)
// BITBUCKET_PR_ID PR number
// BITBUCKET_REPO_OWNER / REPO_SLUG
type BitbucketPipelines struct{}

func NewBitbucketPipelines() Provider { return &BitbucketPipelines{} }

func (b *BitbucketPipelines) Platform() Platform { return PlatformBitbucketPipelines }

func (b *BitbucketPipelines) Detect() bool {
return os.Getenv("BITBUCKET_BUILD_NUMBER") != ""
}

func (b *BitbucketPipelines) BaseRef(ctx context.Context, repoPath string) (string, error) {
if v := os.Getenv("BITBUCKET_PR_DESTINATION_BRANCH"); v != "" {
return resolveRef(ctx, repoPath, "origin/"+v)
}
return resolveRef(ctx, repoPath, "HEAD^")
}

func (b *BitbucketPipelines) HeadRef(ctx context.Context, repoPath string) (string, error) {
if v := os.Getenv("BITBUCKET_COMMIT"); v != "" {
return v, nil
}
return resolveRef(ctx, repoPath, "HEAD")
}

func (b *BitbucketPipelines) PRNumber() (int, bool) {
v := os.Getenv("BITBUCKET_PR_ID")
if v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return n, true
}

func (b *BitbucketPipelines) RepoSlug() (string, string, bool) {
owner := os.Getenv("BITBUCKET_REPO_OWNER")
name := os.Getenv("BITBUCKET_REPO_SLUG")
if owner == "" || name == "" {
return "", "", false
}
return owner, name, true
}

func (b *BitbucketPipelines) EnrichHeader(h http.Header) {
if v := os.Getenv("BITBUCKET_BUILD_NUMBER"); v != "" {
h.Set("X-Nullify-CI-Run-ID", v)
}
if v := os.Getenv("BITBUCKET_COMMIT"); v != "" {
h.Set("X-Nullify-CI-Commit", v)
}
h.Set("X-Nullify-CI-Provider", b.Platform().String())
}
86 changes: 86 additions & 0 deletions internal/ci/ci.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Package ci detects the CI/CD platform the CLI is currently running under
// and exposes the base/head commit refs it needs to compute changed
// dependencies.
//
// Implementations are registered in priority order in registry.go.
// Detect() returns the first provider whose env-var signature matches;
// the Local fallback is last so an unrecognised CI still works off
// `git rev-parse` defaults.
//
// Each provider reports a Platform value from the locally-defined set of
// constants below. When adding a provider, define its constant here and
// return it from Platform().
package ci

import (
"context"
"errors"
"net/http"
)

// Platform is the canonical identifier for a CI/CD platform. Values are
// stamped onto the X-Nullify-CI-Provider header so scpm's audit log can
// attribute a CLI run to its CI environment.
type Platform string

const (
PlatformGitHubActions Platform = "GITHUB_ACTIONS"
PlatformGitLabCI Platform = "GITLAB_CI"
PlatformCircleCI Platform = "CIRCLECI"
PlatformBitbucketPipelines Platform = "BITBUCKET_PIPELINES"
PlatformJenkins Platform = "JENKINS"
PlatformAzureDevOps Platform = "AZURE_DEVOPS"
PlatformGoogleCloudBuild Platform = "GOOGLE_CLOUD_BUILD"
PlatformAWSCodeBuild Platform = "AWS_CODEBUILD"
PlatformOther Platform = "OTHER"
)

func (p Platform) String() string { return string(p) }

// Provider identifies one CI/CD platform and exposes the information
// the CLI's deps-analyze + containers-analyze workflows need. All
// methods are expected to be cheap — provider detection happens on
// every CLI invocation.
type Provider interface {
// Platform returns this provider's Platform constant.
Platform() Platform

// Detect returns true when the current process env matches this
// provider's signature. Detect MUST NOT touch the network or
// filesystem — env var inspection only.
Detect() bool

// BaseRef returns the commit or ref (short sha, full sha, or
// branch name) the CI declared as the base of the current build,
// resolved against the git repository at repoPath. For PR builds,
// this is the PR target branch's HEAD at PR open time; for push
// builds, it's the previous HEAD of the pushed branch. Fall back to
// "origin/<default-branch>" when CI doesn't expose a specific base.
BaseRef(ctx context.Context, repoPath string) (string, error)

// HeadRef returns the commit the current build is running against,
// resolved against the git repository at repoPath. For PR builds,
// this is the PR's head commit; for push builds, the pushed commit.
HeadRef(ctx context.Context, repoPath string) (string, error)

// PRNumber returns the pull-request number if the current build is
// a PR, and (0, false) otherwise. Used for diagnostic logging + as
// the idempotency-key prefix in scpm calls.
PRNumber() (int, bool)

// RepoSlug returns (owner, name) for GitHub/GitLab/Bitbucket-style
// coordinates. Returns (_, _, false) when the provider doesn't
// expose it (eg. Jenkins, self-hosted CI).
RepoSlug() (owner, name string, ok bool)

// EnrichHeader adds CI-specific headers to outbound HTTP requests
// (commit SHA, PR number, run ID) so scpm's audit log can tie a
// specific CLI run back to a CI invocation. Called once per HTTP
// request built by the workflow.
EnrichHeader(h http.Header)
}

// ErrNoProvider is returned by Detect when no registered provider
// matches — shouldn't happen in practice because the Local fallback
// always returns true, but declared so callers can assert on it.
var ErrNoProvider = errors.New("no CI provider detected")
Loading