Skip to content

Commit 2e585c8

Browse files
committed
Default source-change enforcement to any
Allow --deny-dependency-source-change without a value to enforce both Git and URL transitions. Retain explicit single-source selection through =git and =url, normalize and deduplicate policy values, expose any consistently through configuration and MCP guidance, and cover the behavior in unit and pinned smoke tests.
1 parent f7f7593 commit 2e585c8

11 files changed

Lines changed: 135 additions & 31 deletions

File tree

docs/CONFIG_REFERENCE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ YAML files use the nested keys documented below. Unknown keys and the former fla
3939
| `policy.license_exempt_packages` | `BOMLY_LICENSE_EXEMPT_PACKAGES` | `[]string` | - | Package URLs exempt from license policy checks |
4040
| `policy.deny_packages` | `BOMLY_DENY_PACKAGES` | `[]string` | - | Package URLs to deny |
4141
| `policy.deny_groups` | `BOMLY_DENY_GROUPS` | `[]string` | - | Package URL namespaces to deny |
42-
| `policy.deny_dependency_source_changes` | `BOMLY_DENY_DEPENDENCY_SOURCE_CHANGES` | `[]string` | - | Diff-only dependency source changes that fail policy: git or url |
42+
| `policy.deny_dependency_source_changes` | `BOMLY_DENY_DEPENDENCY_SOURCE_CHANGES` | `[]string` | - | Diff-only dependency source changes that fail policy: any, git, or url |
4343
| `policy.protected_packages` | `BOMLY_PROTECTED_PACKAGES` | `[]string` | - | Canonical package names to protect from typosquatting |
4444
| `policy.typosquat_threshold` | `BOMLY_TYPOSQUAT_THRESHOLD` | `string` | 0.90 | Similarity threshold for typosquatting detection |
4545
| `policy.typosquat_mode` | `BOMLY_TYPOSQUAT_MODE` | `string` | warn | Typosquatting policy mode: warn or fail |
@@ -210,7 +210,7 @@ Flat YAML keys are no longer accepted. Move each existing key to its nested repl
210210
# deny_packages: []
211211
# Package URL namespaces to deny
212212
# deny_groups: []
213-
# Diff-only dependency source changes that fail policy: git or url
213+
# Diff-only dependency source changes that fail policy: any, git, or url
214214
# deny_dependency_source_changes: []
215215
# Canonical package names to protect from typosquatting
216216
# protected_packages: []

docs/auditors/package.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ use the dependency details already produced by an enriched diff.
3434
| --- | --- | --- |
3535
| `--deny-package <name>` | `policy.deny_packages` | Fail when this package is present. Repeatable. |
3636
| `--deny-group <group>` | `policy.deny_groups` | Fail on any package in this group/namespace (e.g. a Maven groupId). Repeatable. |
37-
| `--deny-dependency-source-change <git\|url>` | `policy.deny_dependency_source_changes` | Make a matching source change fail instead of warn. Repeatable and diff-only. |
37+
| `--deny-dependency-source-change[=any\|git\|url]` | `policy.deny_dependency_source_changes` | Make a matching source change fail instead of warn. Without a value, `any` covers both Git and URL. Use `=git` or `=url` to select one. Repeatable and diff-only. |
3838
| `--protected-package <name>` | `policy.protected_packages` | A trusted name; lookalikes within the threshold are flagged as possible typosquats. Repeatable. |
3939
| `--typosquat-threshold <0..1>` | `policy.typosquat_threshold` | Similarity score above which a name is treated as a lookalike. Default `0.90`. Higher = stricter (fewer matches). |
4040
| `--typosquat-mode <warn\|fail>` | `policy.typosquat_mode` | Policy status for a typosquat finding. `warn` (default) records a warning; `fail` makes it eligible to fail when it also matches `--fail-on`. |
@@ -56,9 +56,9 @@ bomly scan --enrich --audit \
5656
--typosquat-threshold 0.85 --typosquat-mode fail \
5757
--fail-on any
5858

59-
# Reject a registry dependency that changes to a Git source
59+
# Reject a registry dependency that changes to Git or a URL
6060
bomly diff --base main --head HEAD --enrich --audit \
61-
--deny-dependency-source-change git
61+
--deny-dependency-source-change
6262
```
6363

6464
## Diff and baselines

internal/cli/opts/flag_options.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/bomly-dev/bomly-cli/internal/config"
1010
"github.com/bomly-dev/bomly-cli/internal/engine"
11+
"github.com/bomly-dev/bomly-cli/sdk"
1112
"github.com/spf13/cobra"
1213
"github.com/spf13/pflag"
1314
"go.uber.org/zap"
@@ -64,12 +65,14 @@ func BindCommandFlagGroups(cmd *cobra.Command, cfg *config.Resolved, groups ...F
6465
}
6566

6667
func bindDiffPolicyFlags(flags *pflag.FlagSet, cfg *config.Resolved) {
68+
const flagName = "deny-dependency-source-change"
6769
flags.StringArrayVar(
6870
&cfg.DenyDependencySourceChanges,
69-
"deny-dependency-source-change",
71+
flagName,
7072
nil,
71-
"Dependency source change that fails diff policy: git or url. Repeatable (requires --audit)",
73+
"Dependency source change that fails diff policy. Without a value, both Git and URL are denied; use =git or =url to select one. Repeatable (requires --audit)",
7274
)
75+
flags.Lookup(flagName).NoOptDefVal = sdk.DependencySourceChangePolicyAny
7376
}
7477

7578
func bindTargetFlags(flags *pflag.FlagSet, cfg *config.Resolved) {

internal/cli/opts/flag_options_test.go

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,22 +101,67 @@ func TestApplyFlagOverridesJSONShortcut(t *testing.T) {
101101
}
102102

103103
func TestBindDiffPolicyFlags(t *testing.T) {
104+
tests := []struct {
105+
name string
106+
args []string
107+
want []string
108+
}{
109+
{
110+
name: "explicit repeated values",
111+
args: []string{
112+
"--deny-dependency-source-change=git",
113+
"--deny-dependency-source-change=url",
114+
},
115+
want: []string{"git", "url"},
116+
},
117+
{
118+
name: "omitted value means any",
119+
args: []string{"--deny-dependency-source-change"},
120+
want: []string{"any"},
121+
},
122+
}
123+
for _, test := range tests {
124+
t.Run(test.name, func(t *testing.T) {
125+
root := newTestRootCommand(t)
126+
var resolved config.Resolved
127+
if err := BindCommandFlagGroups(root, &resolved, FlagGroupDiffPolicy); err != nil {
128+
t.Fatal(err)
129+
}
130+
if err := root.ParseFlags(test.args); err != nil {
131+
t.Fatal(err)
132+
}
133+
var merged config.Resolved
134+
applyFlagOverrides(&merged, resolved, root)
135+
if len(merged.DenyDependencySourceChanges) != len(test.want) {
136+
t.Fatalf("source-change flags = %#v, want %#v", merged.DenyDependencySourceChanges, test.want)
137+
}
138+
for i := range test.want {
139+
if merged.DenyDependencySourceChanges[i] != test.want[i] {
140+
t.Fatalf("source-change flags = %#v, want %#v", merged.DenyDependencySourceChanges, test.want)
141+
}
142+
}
143+
})
144+
}
145+
}
146+
147+
func TestBindValuelessDiffPolicyFlagBeforeOtherFlags(t *testing.T) {
104148
root := newTestRootCommand(t)
105149
var resolved config.Resolved
106-
if err := BindCommandFlagGroups(root, &resolved, FlagGroupDiffPolicy); err != nil {
150+
if err := BindCommandFlagGroups(root, &resolved, FlagGroupDiffPolicy, FlagGroupAnalysis); err != nil {
107151
t.Fatal(err)
108152
}
109153
if err := root.ParseFlags([]string{
110-
"--deny-dependency-source-change", "git",
111-
"--deny-dependency-source-change", "url",
154+
"--deny-dependency-source-change",
155+
"--enrich",
156+
"--audit",
112157
}); err != nil {
113158
t.Fatal(err)
114159
}
115-
var merged config.Resolved
116-
applyFlagOverrides(&merged, resolved, root)
117-
if len(merged.DenyDependencySourceChanges) != 2 ||
118-
merged.DenyDependencySourceChanges[0] != "git" ||
119-
merged.DenyDependencySourceChanges[1] != "url" {
120-
t.Fatalf("source-change flags = %#v", merged.DenyDependencySourceChanges)
160+
if len(resolved.DenyDependencySourceChanges) != 1 ||
161+
resolved.DenyDependencySourceChanges[0] != "any" {
162+
t.Fatalf("source-change flags = %#v, want any", resolved.DenyDependencySourceChanges)
163+
}
164+
if !resolved.Enrich || !resolved.Audit {
165+
t.Fatalf("following flags were not parsed: enrich=%t audit=%t", resolved.Enrich, resolved.Audit)
121166
}
122167
}

internal/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ type Resolved struct {
3838
LicenseExemptPackages []string `doc:"Package URLs exempt from license policy checks" env:"BOMLY_LICENSE_EXEMPT_PACKAGES"`
3939
DenyPackages []string `doc:"Package URLs to deny" env:"BOMLY_DENY_PACKAGES"`
4040
DenyGroups []string `doc:"Package URL namespaces to deny" env:"BOMLY_DENY_GROUPS"`
41-
DenyDependencySourceChanges []string `doc:"Diff-only dependency source changes that fail policy: git or url" env:"BOMLY_DENY_DEPENDENCY_SOURCE_CHANGES"`
41+
DenyDependencySourceChanges []string `doc:"Diff-only dependency source changes that fail policy: any, git, or url" env:"BOMLY_DENY_DEPENDENCY_SOURCE_CHANGES"`
4242
ProtectedPackages []string `doc:"Canonical package names to protect from typosquatting" env:"BOMLY_PROTECTED_PACKAGES"`
4343
TyposquatThreshold string `doc:"Similarity threshold for typosquatting detection" env:"BOMLY_TYPOSQUAT_THRESHOLD" default:"0.90"`
4444
TyposquatMode string `doc:"Typosquatting policy mode: warn or fail" env:"BOMLY_TYPOSQUAT_MODE" default:"warn"`

internal/config/validate_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func TestValidateDependencySourceChangePolicy(t *testing.T) {
4949
if err := Validate(Resolved{
5050
Enrich: true,
5151
Audit: true,
52-
DenyDependencySourceChanges: []string{"git", "url"},
52+
DenyDependencySourceChanges: []string{"any"},
5353
}); err != nil {
5454
t.Fatalf("valid source-change policy rejected: %v", err)
5555
}
@@ -63,7 +63,7 @@ func TestValidateDependencySourceChangePolicy(t *testing.T) {
6363
Enrich: true,
6464
Audit: true,
6565
DenyDependencySourceChanges: []string{"workspace"},
66-
}); err == nil || !strings.Contains(err.Error(), "accepted: git, url") {
66+
}); err == nil || !strings.Contains(err.Error(), "accepted: any, git, url") {
6767
t.Fatalf("invalid source error = %v", err)
6868
}
6969
}

internal/mcp/tool_diff.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func registerDiffTool(s *server.MCPServer, mcpCtx Context) {
3232
mcplib.WithString("license_exempt_packages", mcplib.Description("Comma-separated package URLs exempt from license checks")),
3333
mcplib.WithString("deny_packages", mcplib.Description("Comma-separated package URLs to deny")),
3434
mcplib.WithString("deny_groups", mcplib.Description("Comma-separated package URL namespaces to deny")),
35-
mcplib.WithString("deny_dependency_source_changes", mcplib.Description("Comma-separated dependency source changes that fail diff policy: git or url (requires audit)")),
35+
mcplib.WithString("deny_dependency_source_changes", mcplib.Description("Comma-separated dependency source changes that fail diff policy: any, git, or url (requires audit)")),
3636
mcplib.WithString("protected_packages", mcplib.Description("Comma-separated package names to protect from typosquatting")),
3737
mcplib.WithString("typosquat_threshold", mcplib.Description("Typosquatting similarity threshold")),
3838
mcplib.WithString("typosquat_mode", mcplib.Description("Typosquatting mode: warn or fail")),

internal/support/prose/auditors/package.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use the dependency details already produced by an enriched diff.
1515
| --- | --- | --- |
1616
| `--deny-package <name>` | `policy.deny_packages` | Fail when this package is present. Repeatable. |
1717
| `--deny-group <group>` | `policy.deny_groups` | Fail on any package in this group/namespace (e.g. a Maven groupId). Repeatable. |
18-
| `--deny-dependency-source-change <git\|url>` | `policy.deny_dependency_source_changes` | Make a matching source change fail instead of warn. Repeatable and diff-only. |
18+
| `--deny-dependency-source-change[=any\|git\|url]` | `policy.deny_dependency_source_changes` | Make a matching source change fail instead of warn. Without a value, `any` covers both Git and URL. Use `=git` or `=url` to select one. Repeatable and diff-only. |
1919
| `--protected-package <name>` | `policy.protected_packages` | A trusted name; lookalikes within the threshold are flagged as possible typosquats. Repeatable. |
2020
| `--typosquat-threshold <0..1>` | `policy.typosquat_threshold` | Similarity score above which a name is treated as a lookalike. Default `0.90`. Higher = stricter (fewer matches). |
2121
| `--typosquat-mode <warn\|fail>` | `policy.typosquat_mode` | Policy status for a typosquat finding. `warn` (default) records a warning; `fail` makes it eligible to fail when it also matches `--fail-on`. |
@@ -37,9 +37,9 @@ bomly scan --enrich --audit \
3737
--typosquat-threshold 0.85 --typosquat-mode fail \
3838
--fail-on any
3939

40-
# Reject a registry dependency that changes to a Git source
40+
# Reject a registry dependency that changes to Git or a URL
4141
bomly diff --base main --head HEAD --enrich --audit \
42-
--deny-dependency-source-change git
42+
--deny-dependency-source-change
4343
```
4444

4545
## Diff and baselines

sdk/dependency_source.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,36 @@ const (
1717
DependencySourceURL DependencySource = "url"
1818
)
1919

20+
// DependencySourceChangePolicyAny selects every source type supported by the
21+
// dependency source-change policy.
22+
const DependencySourceChangePolicyAny = "any"
23+
2024
// ParseDependencySourceChangePolicies parses source types accepted by the
2125
// dependency source-change policy.
2226
func ParseDependencySourceChangePolicies(values []string) ([]DependencySource, error) {
23-
parsed := make([]DependencySource, 0, len(values))
27+
includeGit := false
28+
includeURL := false
2429
for _, value := range values {
25-
source := DependencySource(strings.ToLower(strings.TrimSpace(value)))
26-
switch source {
27-
case DependencySourceGit, DependencySourceURL:
28-
parsed = append(parsed, source)
30+
normalized := strings.ToLower(strings.TrimSpace(value))
31+
switch normalized {
32+
case DependencySourceChangePolicyAny:
33+
includeGit = true
34+
includeURL = true
35+
case string(DependencySourceGit):
36+
includeGit = true
37+
case string(DependencySourceURL):
38+
includeURL = true
2939
default:
30-
return nil, fmt.Errorf("unsupported dependency source change %q (accepted: git, url)", value)
40+
return nil, fmt.Errorf("unsupported dependency source change %q (accepted: any, git, url)", value)
3141
}
3242
}
43+
parsed := make([]DependencySource, 0, 2)
44+
if includeGit {
45+
parsed = append(parsed, DependencySourceGit)
46+
}
47+
if includeURL {
48+
parsed = append(parsed, DependencySourceURL)
49+
}
3350
return parsed, nil
3451
}
3552

sdk/dependency_source_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,42 @@ func TestDependencyRegistryMatchEligible(t *testing.T) {
3030
})
3131
}
3232
}
33+
34+
func TestParseDependencySourceChangePolicies(t *testing.T) {
35+
tests := []struct {
36+
name string
37+
values []string
38+
want []DependencySource
39+
wantErr bool
40+
}{
41+
{name: "empty"},
42+
{name: "Git", values: []string{"git"}, want: []DependencySource{DependencySourceGit}},
43+
{name: "URL", values: []string{"url"}, want: []DependencySource{DependencySourceURL}},
44+
{name: "both", values: []string{"url", "git"}, want: []DependencySource{DependencySourceGit, DependencySourceURL}},
45+
{name: "any", values: []string{"ANY"}, want: []DependencySource{DependencySourceGit, DependencySourceURL}},
46+
{name: "deduplicated", values: []string{"any", "git", "url"}, want: []DependencySource{DependencySourceGit, DependencySourceURL}},
47+
{name: "unsupported", values: []string{"workspace"}, wantErr: true},
48+
}
49+
for _, test := range tests {
50+
t.Run(test.name, func(t *testing.T) {
51+
got, err := ParseDependencySourceChangePolicies(test.values)
52+
if test.wantErr {
53+
if err == nil {
54+
t.Fatalf("ParseDependencySourceChangePolicies(%#v) expected error", test.values)
55+
}
56+
return
57+
}
58+
if err != nil {
59+
t.Fatalf("ParseDependencySourceChangePolicies(%#v) error = %v", test.values, err)
60+
}
61+
if len(got) != len(test.want) {
62+
t.Fatalf("ParseDependencySourceChangePolicies(%#v) = %#v, want %#v", test.values, got, test.want)
63+
}
64+
for i := range test.want {
65+
if got[i] != test.want[i] {
66+
t.Fatalf("ParseDependencySourceChangePolicies(%#v) = %#v, want %#v", test.values, got, test.want)
67+
}
68+
}
69+
})
70+
}
71+
}

0 commit comments

Comments
 (0)