Skip to content

Commit 8882996

Browse files
feat(cli): nullify deps analyze — CI-aware dependency malware-analysis workflow (#142)
* feat(cli): nullify deps analyze — CI-aware dependency malware-analysis workflow Hand-written workflow command (not generated from OpenAPI) that wires the Phase 5a/5b malware analyzer into customer CI pipelines via scpm's /scpm/dependencies/analyze endpoint. Components: - internal/ci/ — extensible CI provider registry. Nine concrete Providers: GitHubActions, GitLabCI, CircleCI, BitbucketPipelines, Jenkins, AzureDevOps, GoogleCloudBuild, AWSCodeBuild, Local (last; always matches). Each exposes Platform() (matches the benchmarks.PipelinePlatform enum), Detect() (env-var signature), BaseRef / HeadRef (context-aware; fall back to `git rev-parse origin/HEAD` when CI doesn't expose a base), PRNumber, RepoSlug, and EnrichHeader (stamps CI metadata on outbound HTTP so scpm's audit log can tie back to the CI run). - internal/scan/manifest/ — per-ecosystem lockfile parsers: npm (package-lock.json v2/v3), pypi (requirements.txt), go (go.sum), rust (Cargo.lock), ruby (Gemfile.lock). Each implements Parser and is registered in manifest.DefaultParsers(). Deliberately tight: pnpm-lock, poetry.lock, pom.xml, composer.lock, packages.lock.json are follow-up work. Every parser produces the same flat Entry (Ecosystem, Name, Version, File, Direct) shape so diff.go can compare them uniformly. - internal/scan/diff.go — the (base, head) diff. Uses `git diff --name-only base..head` to list changed files, filters to those a parser matches, then `git show <ref>:<path>` on each side + parse + set-difference. Produces ChangedDep{added, bumped, removed}. - internal/commands/deps_analyze.go — the cobra command. Flow: 1. Detect CI provider; resolve base (--base or provider.BaseRef) and head (--head or provider.HeadRef) to commit SHAs. 2. scan.Diff → list of changed deps. 3. For each added/bumped dep, POST to {client.BaseURL}/scpm/dependencies/analyze with the CI-provider headers + an idempotency key derived from CI run ID + package tuple (so re-runs of the same CI build hit cached jobs). 4. Render results as text (default) or json. 5. Exit per --fail-on (none|vulnerable|suspicious|malicious): 0=clean, 10=vulnerable, 20=suspicious, 30=malicious, 2=invalid invocation, 1=transient. The scpm calls are hand-rolled HTTP POSTs rather than generated api.Client methods because scpm's OpenAPI regen happens in a separate pipeline — the CLI's scripts/generate/main.go will supersede these once the spec lands. Follows the RegisterContextPushCommand pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(cli): gofmt doc-comment blocks in CI provider files golangci-lint v2.12.0 gofmt flagged the space-indented env-var tables in the AWS CodeBuild / Azure DevOps / Bitbucket provider doc comments; reflow to the Go 1.19 preformatted form (blank // + tab indent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(cli): gofmt remaining deps-analyze files Reflow the rest of the CI-provider doc-comment tables to Go 1.19 preformatted form, and fix const/var/struct-tag alignment in deps_analyze.go and npm_lock.go. golangci-lint caps gofmt reports at 3 per run, so these surfaced only after the first batch was fixed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): make deps-analyze gate functional + add tests Addresses review of the deps-analyze workflow. Blockers: - Wire ExitCodeFromError in main.go so the severity exit codes (10/20/30) actually fire — previously every result exited 1, so the CI merge-gate was inert. Set SilenceUsage so a verdict failure doesn't print cobra usage. - Fix --fail-on=none, which always exited 10: checkFailOn now treats a zero-rank threshold as "never fail". Correctness: - renderResults no longer index-zips two parallel slices (a mid-list analyze failure misattributed verdicts to the wrong package and dropped the tail). One analyzedDep{dep,resp,err} entry per dep now. - Unknown/unrecognised non-empty verdicts fail closed: warn and treat as malicious so a server-side rename can't silently bypass the gate. - Provider BaseRef/HeadRef + resolveRef take repoPath and set cmd.Dir, so --repo is honoured (ref resolution no longer runs in the CWD). Feature: - Implement --wait (was parsed but unused): poll by re-POSTing the idempotent analyze request until the job is terminal or --wait-timeout elapses; adds --wait-timeout/--wait-interval. An incomplete analysis now exits transiently (1) instead of greenlighting the merge. Cleanup: - Typed enums replace magic strings: ci.Platform, scan.ChangeKind, manifest.Ecosystem, commands.Verdict. - Remove the `var _ = fmt.Sprintf` hack and the //nolint:errcheck. - Correct the doc comments that claimed a benchmarks.PipelinePlatform enum and a `make lint-ci-providers` target that don't exist. - Fix the misleading idempotency-key comment; handle SSH repo URLs in AWSCodeBuild.RepoSlug; note fetch-depth:0 for GitHub push diffs. Tests: - Add unit tests for the five lockfile parsers, diffEntries, CI provider detection/parsing, and the verdict/gate/render/wait/exit-code logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c740b98 commit 8882996

25 files changed

Lines changed: 2788 additions & 1 deletion

‎cmd/cli/cmd/root.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ func init() {
104104
commands.RegisterOrchestratorCommands(apiCmd, getAPIClient)
105105
commands.RegisterAssetGraphCommands(apiCmd, getAPIClient)
106106
commands.RegisterInfrastructureCommands(apiCmd, getAPIClient)
107+
108+
// Hand-written workflow — not generated from OpenAPI. Routes through
109+
// scpm's /scpm/dependencies/analyze. Wired at top level (not under
110+
// apiCmd) so `nullify deps analyze` reads naturally in CI scripts.
111+
commands.RegisterDepsAnalyzeCommand(rootCmd, getAPIClient)
107112
}
108113

109114
func setupLogger(ctx context.Context) context.Context {

‎cmd/cli/main.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import (
44
"os"
55

66
"github.com/nullify-platform/cli/cmd/cli/cmd"
7+
"github.com/nullify-platform/cli/internal/commands"
78
)
89

910
func main() {
11+
// commands.ExitCodeFromError maps the deps-analyze workflow's
12+
// exit-coded errors (severity gate: 10/20/30, invalid: 2, transient:
13+
// 1) onto the process exit code; any other error falls back to 1.
1014
if err := cmd.Execute(); err != nil {
11-
os.Exit(1)
15+
os.Exit(commands.ExitCodeFromError(err))
1216
}
1317
}

‎internal/ci/aws_codebuild.go‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package ci
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"os"
7+
"strings"
8+
)
9+
10+
// AWSCodeBuild — https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html
11+
//
12+
// Key envs:
13+
//
14+
// CODEBUILD_BUILD_ID signature
15+
// CODEBUILD_RESOLVED_SOURCE_VERSION head
16+
// CODEBUILD_WEBHOOK_BASE_REF base (webhook-triggered PRs)
17+
// CODEBUILD_SOURCE_REPO_URL https://github.com/org/repo.git
18+
type AWSCodeBuild struct{}
19+
20+
func NewAWSCodeBuild() Provider { return &AWSCodeBuild{} }
21+
22+
func (a *AWSCodeBuild) Platform() Platform { return PlatformAWSCodeBuild }
23+
24+
func (a *AWSCodeBuild) Detect() bool { return os.Getenv("CODEBUILD_BUILD_ID") != "" }
25+
26+
func (a *AWSCodeBuild) BaseRef(ctx context.Context, repoPath string) (string, error) {
27+
// CODEBUILD_WEBHOOK_BASE_REF is "refs/heads/main" shape — strip the
28+
// prefix + resolve.
29+
if v := os.Getenv("CODEBUILD_WEBHOOK_BASE_REF"); v != "" {
30+
return resolveRef(ctx, repoPath, "origin/"+strings.TrimPrefix(v, "refs/heads/"))
31+
}
32+
return resolveRef(ctx, repoPath, "origin/HEAD")
33+
}
34+
35+
func (a *AWSCodeBuild) HeadRef(ctx context.Context, repoPath string) (string, error) {
36+
if v := os.Getenv("CODEBUILD_RESOLVED_SOURCE_VERSION"); v != "" {
37+
return v, nil
38+
}
39+
return resolveRef(ctx, repoPath, "HEAD")
40+
}
41+
42+
func (a *AWSCodeBuild) PRNumber() (int, bool) {
43+
// CODEBUILD_WEBHOOK_TRIGGER sometimes has "pr/123" shape.
44+
trigger := os.Getenv("CODEBUILD_WEBHOOK_TRIGGER")
45+
if !strings.HasPrefix(trigger, "pr/") {
46+
return 0, false
47+
}
48+
n := 0
49+
for _, c := range trigger[3:] {
50+
if c < '0' || c > '9' {
51+
return 0, false
52+
}
53+
n = n*10 + int(c-'0')
54+
}
55+
return n, true
56+
}
57+
58+
func (a *AWSCodeBuild) RepoSlug() (string, string, bool) {
59+
repoURL := os.Getenv("CODEBUILD_SOURCE_REPO_URL")
60+
if repoURL == "" {
61+
return "", "", false
62+
}
63+
// Strip trailing .git. Handle both HTTPS
64+
// ("https://github.com/owner/repo.git") and SSH
65+
// ("git@github.com:owner/repo.git") forms: in the SSH shape the
66+
// owner is separated from the host by ':', so normalise that to '/'
67+
// before taking the final "owner/name" segments.
68+
trimmed := strings.TrimSuffix(repoURL, ".git")
69+
trimmed = strings.ReplaceAll(trimmed, ":", "/")
70+
parts := strings.Split(trimmed, "/")
71+
if len(parts) < 2 {
72+
return "", "", false
73+
}
74+
return parts[len(parts)-2], parts[len(parts)-1], true
75+
}
76+
77+
func (a *AWSCodeBuild) EnrichHeader(h http.Header) {
78+
if v := os.Getenv("CODEBUILD_BUILD_ID"); v != "" {
79+
h.Set("X-Nullify-CI-Run-ID", v)
80+
}
81+
if v := os.Getenv("CODEBUILD_RESOLVED_SOURCE_VERSION"); v != "" {
82+
h.Set("X-Nullify-CI-Commit", v)
83+
}
84+
h.Set("X-Nullify-CI-Provider", a.Platform().String())
85+
}

‎internal/ci/azure_devops.go‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package ci
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"os"
7+
"strconv"
8+
)
9+
10+
// AzureDevOps — https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables
11+
//
12+
// Key envs:
13+
//
14+
// TF_BUILD=True signature
15+
// BUILD_SOURCEVERSION head commit
16+
// SYSTEM_PULLREQUEST_TARGETBRANCHNAME target branch (PR builds)
17+
// SYSTEM_PULLREQUEST_PULLREQUESTNUMBER PR number
18+
// BUILD_REPOSITORY_NAME repo name (no owner)
19+
// BUILD_BUILDID run id
20+
type AzureDevOps struct{}
21+
22+
func NewAzureDevOps() Provider { return &AzureDevOps{} }
23+
24+
func (a *AzureDevOps) Platform() Platform { return PlatformAzureDevOps }
25+
26+
func (a *AzureDevOps) Detect() bool { return os.Getenv("TF_BUILD") == "True" }
27+
28+
func (a *AzureDevOps) BaseRef(ctx context.Context, repoPath string) (string, error) {
29+
if v := os.Getenv("SYSTEM_PULLREQUEST_TARGETBRANCHNAME"); v != "" {
30+
return resolveRef(ctx, repoPath, "origin/"+v)
31+
}
32+
return resolveRef(ctx, repoPath, "HEAD^")
33+
}
34+
35+
func (a *AzureDevOps) HeadRef(ctx context.Context, repoPath string) (string, error) {
36+
if v := os.Getenv("BUILD_SOURCEVERSION"); v != "" {
37+
return v, nil
38+
}
39+
return resolveRef(ctx, repoPath, "HEAD")
40+
}
41+
42+
func (a *AzureDevOps) PRNumber() (int, bool) {
43+
v := os.Getenv("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")
44+
if v == "" {
45+
return 0, false
46+
}
47+
n, err := strconv.Atoi(v)
48+
if err != nil {
49+
return 0, false
50+
}
51+
return n, true
52+
}
53+
54+
func (a *AzureDevOps) RepoSlug() (string, string, bool) {
55+
// Azure uses Organization/Project/Repo — not a two-part slug.
56+
// Collapse Organization as the "owner" field.
57+
owner := os.Getenv("SYSTEM_COLLECTIONURI")
58+
name := os.Getenv("BUILD_REPOSITORY_NAME")
59+
if name == "" {
60+
return "", "", false
61+
}
62+
return owner, name, true
63+
}
64+
65+
func (a *AzureDevOps) EnrichHeader(h http.Header) {
66+
if v := os.Getenv("BUILD_BUILDID"); v != "" {
67+
h.Set("X-Nullify-CI-Run-ID", v)
68+
}
69+
if v := os.Getenv("BUILD_SOURCEVERSION"); v != "" {
70+
h.Set("X-Nullify-CI-Commit", v)
71+
}
72+
h.Set("X-Nullify-CI-Provider", a.Platform().String())
73+
}

‎internal/ci/bitbucket_pipelines.go‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package ci
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"os"
7+
"strconv"
8+
)
9+
10+
// BitbucketPipelines — https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/
11+
//
12+
// Key envs:
13+
//
14+
// BITBUCKET_BUILD_NUMBER signature
15+
// BITBUCKET_COMMIT head
16+
// BITBUCKET_PR_DESTINATION_BRANCH target branch (PR builds)
17+
// BITBUCKET_PR_ID PR number
18+
// BITBUCKET_REPO_OWNER / REPO_SLUG
19+
type BitbucketPipelines struct{}
20+
21+
func NewBitbucketPipelines() Provider { return &BitbucketPipelines{} }
22+
23+
func (b *BitbucketPipelines) Platform() Platform { return PlatformBitbucketPipelines }
24+
25+
func (b *BitbucketPipelines) Detect() bool {
26+
return os.Getenv("BITBUCKET_BUILD_NUMBER") != ""
27+
}
28+
29+
func (b *BitbucketPipelines) BaseRef(ctx context.Context, repoPath string) (string, error) {
30+
if v := os.Getenv("BITBUCKET_PR_DESTINATION_BRANCH"); v != "" {
31+
return resolveRef(ctx, repoPath, "origin/"+v)
32+
}
33+
return resolveRef(ctx, repoPath, "HEAD^")
34+
}
35+
36+
func (b *BitbucketPipelines) HeadRef(ctx context.Context, repoPath string) (string, error) {
37+
if v := os.Getenv("BITBUCKET_COMMIT"); v != "" {
38+
return v, nil
39+
}
40+
return resolveRef(ctx, repoPath, "HEAD")
41+
}
42+
43+
func (b *BitbucketPipelines) PRNumber() (int, bool) {
44+
v := os.Getenv("BITBUCKET_PR_ID")
45+
if v == "" {
46+
return 0, false
47+
}
48+
n, err := strconv.Atoi(v)
49+
if err != nil {
50+
return 0, false
51+
}
52+
return n, true
53+
}
54+
55+
func (b *BitbucketPipelines) RepoSlug() (string, string, bool) {
56+
owner := os.Getenv("BITBUCKET_REPO_OWNER")
57+
name := os.Getenv("BITBUCKET_REPO_SLUG")
58+
if owner == "" || name == "" {
59+
return "", "", false
60+
}
61+
return owner, name, true
62+
}
63+
64+
func (b *BitbucketPipelines) EnrichHeader(h http.Header) {
65+
if v := os.Getenv("BITBUCKET_BUILD_NUMBER"); v != "" {
66+
h.Set("X-Nullify-CI-Run-ID", v)
67+
}
68+
if v := os.Getenv("BITBUCKET_COMMIT"); v != "" {
69+
h.Set("X-Nullify-CI-Commit", v)
70+
}
71+
h.Set("X-Nullify-CI-Provider", b.Platform().String())
72+
}

‎internal/ci/ci.go‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Package ci detects the CI/CD platform the CLI is currently running under
2+
// and exposes the base/head commit refs it needs to compute changed
3+
// dependencies.
4+
//
5+
// Implementations are registered in priority order in registry.go.
6+
// Detect() returns the first provider whose env-var signature matches;
7+
// the Local fallback is last so an unrecognised CI still works off
8+
// `git rev-parse` defaults.
9+
//
10+
// Each provider reports a Platform value from the locally-defined set of
11+
// constants below. When adding a provider, define its constant here and
12+
// return it from Platform().
13+
package ci
14+
15+
import (
16+
"context"
17+
"errors"
18+
"net/http"
19+
)
20+
21+
// Platform is the canonical identifier for a CI/CD platform. Values are
22+
// stamped onto the X-Nullify-CI-Provider header so scpm's audit log can
23+
// attribute a CLI run to its CI environment.
24+
type Platform string
25+
26+
const (
27+
PlatformGitHubActions Platform = "GITHUB_ACTIONS"
28+
PlatformGitLabCI Platform = "GITLAB_CI"
29+
PlatformCircleCI Platform = "CIRCLECI"
30+
PlatformBitbucketPipelines Platform = "BITBUCKET_PIPELINES"
31+
PlatformJenkins Platform = "JENKINS"
32+
PlatformAzureDevOps Platform = "AZURE_DEVOPS"
33+
PlatformGoogleCloudBuild Platform = "GOOGLE_CLOUD_BUILD"
34+
PlatformAWSCodeBuild Platform = "AWS_CODEBUILD"
35+
PlatformOther Platform = "OTHER"
36+
)
37+
38+
func (p Platform) String() string { return string(p) }
39+
40+
// Provider identifies one CI/CD platform and exposes the information
41+
// the CLI's deps-analyze + containers-analyze workflows need. All
42+
// methods are expected to be cheap — provider detection happens on
43+
// every CLI invocation.
44+
type Provider interface {
45+
// Platform returns this provider's Platform constant.
46+
Platform() Platform
47+
48+
// Detect returns true when the current process env matches this
49+
// provider's signature. Detect MUST NOT touch the network or
50+
// filesystem — env var inspection only.
51+
Detect() bool
52+
53+
// BaseRef returns the commit or ref (short sha, full sha, or
54+
// branch name) the CI declared as the base of the current build,
55+
// resolved against the git repository at repoPath. For PR builds,
56+
// this is the PR target branch's HEAD at PR open time; for push
57+
// builds, it's the previous HEAD of the pushed branch. Fall back to
58+
// "origin/<default-branch>" when CI doesn't expose a specific base.
59+
BaseRef(ctx context.Context, repoPath string) (string, error)
60+
61+
// HeadRef returns the commit the current build is running against,
62+
// resolved against the git repository at repoPath. For PR builds,
63+
// this is the PR's head commit; for push builds, the pushed commit.
64+
HeadRef(ctx context.Context, repoPath string) (string, error)
65+
66+
// PRNumber returns the pull-request number if the current build is
67+
// a PR, and (0, false) otherwise. Used for diagnostic logging + as
68+
// the idempotency-key prefix in scpm calls.
69+
PRNumber() (int, bool)
70+
71+
// RepoSlug returns (owner, name) for GitHub/GitLab/Bitbucket-style
72+
// coordinates. Returns (_, _, false) when the provider doesn't
73+
// expose it (eg. Jenkins, self-hosted CI).
74+
RepoSlug() (owner, name string, ok bool)
75+
76+
// EnrichHeader adds CI-specific headers to outbound HTTP requests
77+
// (commit SHA, PR number, run ID) so scpm's audit log can tie a
78+
// specific CLI run back to a CI invocation. Called once per HTTP
79+
// request built by the workflow.
80+
EnrichHeader(h http.Header)
81+
}
82+
83+
// ErrNoProvider is returned by Detect when no registered provider
84+
// matches — shouldn't happen in practice because the Local fallback
85+
// always returns true, but declared so callers can assert on it.
86+
var ErrNoProvider = errors.New("no CI provider detected")

0 commit comments

Comments
 (0)