Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: 2.1

orbs:
prodsec: snyk/prodsec-orb@1.2.20
prodsec: snyk/prodsec-orb@1

jobs:
security-scans:
Expand All @@ -14,6 +14,7 @@ jobs:
mode: auto
open-source-additional-arguments: --exclude=testdata
iac-scan: disabled
debug: true

workflows:
version: 2
Expand Down
14 changes: 12 additions & 2 deletions application/server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -1457,12 +1457,22 @@ func updateFolderConfigOrg(conf configuration.Configuration, logger *zerolog.Log
orgHasJustChanged := currentSnap.PreferredOrg != oldSnapshot.PreferredOrg
if orgSetByUserJustChanged {
if !currentSnap.OrgSetByUser {
types.SetPreferredOrgAndOrgSetByUser(conf, folderConfig.FolderPath, "", false)
// Only normalise to {"","false"} if the keys are still present. A reset via
// applyPreferredOrg already Unset both; re-writing them here would turn the
// clean absence into an explicit {Value:""/false, Changed:true} entry that
// HasUserOverride treats as an active override.
if types.HasUserOverride(conf, folderConfig.FolderPath, types.SettingPreferredOrg) ||
types.HasUserOverride(conf, folderConfig.FolderPath, types.SettingOrgSetByUser) {
types.SetPreferredOrgAndOrgSetByUser(conf, folderConfig.FolderPath, "", false)
}
}
} else if orgHasJustChanged {
types.SetPreferredOrgAndOrgSetByUser(conf, folderConfig.FolderPath, currentSnap.PreferredOrg, true)
} else if !currentSnap.OrgSetByUser {
types.SetPreferredOrgAndOrgSetByUser(conf, folderConfig.FolderPath, "", false)
if types.HasUserOverride(conf, folderConfig.FolderPath, types.SettingPreferredOrg) ||
types.HasUserOverride(conf, folderConfig.FolderPath, types.SettingOrgSetByUser) {
types.SetPreferredOrgAndOrgSetByUser(conf, folderConfig.FolderPath, "", false)
}
}
}

Expand Down
146 changes: 146 additions & 0 deletions application/server/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,152 @@ func Test_UpdateSettings(t *testing.T) {
})
}

// Test_updateFolderConfig_ResetClearsUserOverrides_EndToEnd drives the full HTML-save reset path:
// the IDE plugin maps each flat-null folder field to {Value:nil, Changed:true}, and UpdateSettings
// must Unset the user:folder: override so the effective value falls back to org/LDX/default.
// preferred_org is the special case — its reset also clears org_set_by_user, reverting to auto/global org.
func Test_updateFolderConfig_ResetClearsUserOverrides_EndToEnd(t *testing.T) {
engine, tokenService := testutil.UnitTestWithEngine(t)
deps := di.TestInit(t, engine, tokenService, nil)
conf := engine.GetConfiguration()
logger := engine.GetLogger()

folderDir := filepath.Join(t.TempDir(), "folder")
require.NoError(t, initTestRepo(t, folderDir))
folderPath := types.FilePath(folderDir)

ctx := ctx2.NewContextWithDependencies(t.Context(), map[string]any{
ctx2.DepNotifier: deps.Notifier,
ctx2.DepAuthService: deps.AuthenticationService,
ctx2.DepConfigResolver: deps.ConfigResolver,
ctx2.DepFeatureFlagService: deps.FeatureFlagService,
ctx2.DepLdxSyncService: deps.LdxSyncService,
ctx2.DepScanStateAggregator: scanstates.NewNoopStateAggregator(),
})
resolver := testutil.DefaultConfigResolver(engine)

// Exercise the production code path: updateFolderOrgIfNeeded is gated on this flag.
conf.Set(types.SettingIsLspInitialized, true)

// Step 1: seed user overrides on the folder (the "user edited overrides" state).
seed := []types.LspFolderConfig{{
FolderPath: folderPath,
Settings: map[string]*types.ConfigSetting{
types.SettingSnykCodeEnabled: {Value: true, Changed: true},
types.SettingScanAutomatic: {Value: true, Changed: true},
types.SettingPreferredOrg: {Value: "my-folder-org", Changed: true},
types.SettingAdditionalParameters: {Value: []string{"--all-projects"}, Changed: true},
types.SettingAdditionalEnvironment: {Value: "FOO=bar", Changed: true},
types.SettingScanCommandConfig: {Value: map[product.Product]types.ScanCommandConfig{
product.ProductOpenSource: {PreScanCommand: "echo pre"},
}, Changed: true},
},
}}
UpdateSettings(ctx, conf, engine, logger, map[string]*types.ConfigSetting{}, seed, analytics.TriggerSourceTest, resolver)

require.True(t, types.HasUserOverride(conf, folderPath, types.SettingSnykCodeEnabled), "snyk_code_enabled override seeded")
require.True(t, types.HasUserOverride(conf, folderPath, types.SettingScanAutomatic), "scan_automatic override seeded")
require.True(t, types.HasUserOverride(conf, folderPath, types.SettingPreferredOrg), "preferred_org override seeded")
require.True(t, types.HasUserOverride(conf, folderPath, types.SettingOrgSetByUser), "org_set_by_user set alongside preferred_org")
require.True(t, types.HasUserOverride(conf, folderPath, types.SettingAdditionalParameters), "additional_parameters override seeded")
require.True(t, types.HasUserOverride(conf, folderPath, types.SettingAdditionalEnvironment), "additional_environment override seeded")
require.True(t, types.HasUserOverride(conf, folderPath, types.SettingScanCommandConfig), "scan_command_config override seeded")

seededFC := config.GetFolderConfigFromEngine(engine, resolver, folderPath, logger)
require.Equal(t, "my-folder-org", seededFC.PreferredOrg(), "seeded preferred_org is effective")

// Step 2: simulate the HTML-save reset — the IDE maps flat-null to {Value:nil, Changed:true}.
reset := []types.LspFolderConfig{{
FolderPath: folderPath,
Settings: map[string]*types.ConfigSetting{
types.SettingSnykCodeEnabled: {Value: nil, Changed: true},
types.SettingScanAutomatic: {Value: nil, Changed: true},
types.SettingPreferredOrg: {Value: nil, Changed: true},
types.SettingAdditionalParameters: {Value: nil, Changed: true},
types.SettingAdditionalEnvironment: {Value: nil, Changed: true},
types.SettingScanCommandConfig: {Value: nil, Changed: true},
},
}}
UpdateSettings(ctx, conf, engine, logger, map[string]*types.ConfigSetting{}, reset, analytics.TriggerSourceTest, resolver)

// Step 3: every user:folder: override is cleared and the underlying key is no longer set.
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingSnykCodeEnabled), "snyk_code_enabled override cleared")
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingScanAutomatic), "scan_automatic override cleared")
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingPreferredOrg), "preferred_org override cleared")
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingOrgSetByUser), "org_set_by_user cleared by org reset")
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingAdditionalParameters), "additional_parameters override cleared")
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingAdditionalEnvironment), "additional_environment override cleared")
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingScanCommandConfig), "scan_command_config override cleared")

// After Unset the key no longer holds an active *LocalConfigField override (Unset writes a
// tombstone, so conf.IsSet may still report true — HasUserOverride is the real contract).
fp := string(types.PathKey(folderPath))
for _, key := range []string{types.SettingSnykCodeEnabled, types.SettingPreferredOrg, types.SettingOrgSetByUser} {
got := conf.Get(configresolver.UserFolderKey(fp, key))
lf, ok := got.(*configresolver.LocalConfigField)
assert.False(t, ok && lf != nil && lf.Changed, "no active override should remain for %s after reset", key)
}

// Effective org reverts to auto/global (empty in this unit setup with no global org).
resetFC := config.GetFolderConfigFromEngine(engine, resolver, folderPath, logger)
assert.False(t, resetFC.OrgSetByUser(), "OrgSetByUser reverts to false after reset")
assert.Empty(t, resetFC.PreferredOrg(), "PreferredOrg falls back after reset")
}

// Test_updateFolderConfig_AutoDeterminedOrgChangeAfterReset guards the else-if branch in
// updateFolderConfigOrg: after a reset clears both overrides, a subsequent update that
// changes only AutoDeterminedOrg must not re-add preferred_org / org_set_by_user.
func Test_updateFolderConfig_AutoDeterminedOrgChangeAfterReset(t *testing.T) {
engine, tokenService := testutil.UnitTestWithEngine(t)
deps := di.TestInit(t, engine, tokenService, nil)
conf := engine.GetConfiguration()
logger := engine.GetLogger()

folderDir := filepath.Join(t.TempDir(), "folder")
require.NoError(t, initTestRepo(t, folderDir))
folderPath := types.FilePath(folderDir)

ctx := ctx2.NewContextWithDependencies(t.Context(), map[string]any{
ctx2.DepNotifier: deps.Notifier,
ctx2.DepAuthService: deps.AuthenticationService,
ctx2.DepConfigResolver: deps.ConfigResolver,
ctx2.DepFeatureFlagService: deps.FeatureFlagService,
ctx2.DepLdxSyncService: deps.LdxSyncService,
ctx2.DepScanStateAggregator: scanstates.NewNoopStateAggregator(),
})
resolver := testutil.DefaultConfigResolver(engine)
conf.Set(types.SettingIsLspInitialized, true)

// Seed a folder org override, then reset it.
seed := []types.LspFolderConfig{{
FolderPath: folderPath,
Settings: map[string]*types.ConfigSetting{
types.SettingPreferredOrg: {Value: "my-folder-org", Changed: true},
},
}}
UpdateSettings(ctx, conf, engine, logger, map[string]*types.ConfigSetting{}, seed, analytics.TriggerSourceTest, resolver)
require.True(t, types.HasUserOverride(conf, folderPath, types.SettingPreferredOrg))

reset := []types.LspFolderConfig{{
FolderPath: folderPath,
Settings: map[string]*types.ConfigSetting{types.SettingPreferredOrg: {Value: nil, Changed: true}},
}}
UpdateSettings(ctx, conf, engine, logger, map[string]*types.ConfigSetting{}, reset, analytics.TriggerSourceTest, resolver)
require.False(t, types.HasUserOverride(conf, folderPath, types.SettingPreferredOrg), "precondition: override cleared")
require.False(t, types.HasUserOverride(conf, folderPath, types.SettingOrgSetByUser), "precondition: org_set_by_user cleared")

// Simulate an AutoDeterminedOrg change (e.g. from LDX-sync) while org-user settings stay empty.
// This triggers the else-if !currentSnap.OrgSetByUser branch in updateFolderConfigOrg.
types.SetAutoDeterminedOrg(conf, folderPath, "ldx-org")

noop := []types.LspFolderConfig{{FolderPath: folderPath, Settings: map[string]*types.ConfigSetting{}}}
UpdateSettings(ctx, conf, engine, logger, map[string]*types.ConfigSetting{}, noop, analytics.TriggerSourceTest, resolver)

// The reset keys must remain absent — the AutoDeterminedOrg change must not re-add them.
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingPreferredOrg), "preferred_org must stay absent after AutoDeterminedOrg change")
assert.False(t, types.HasUserOverride(conf, folderPath, types.SettingOrgSetByUser), "org_set_by_user must stay absent after AutoDeterminedOrg change")
}

func initTestRepo(t *testing.T, tempDir string) error {
t.Helper()
repo1, err := git.PlainInit(tempDir, false)
Expand Down
27 changes: 27 additions & 0 deletions docs/configuration-dialog.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,33 @@ On save (or auto-save), `features/auto-save.js` calls `form-handler.collectData(

See [Saving Configuration Flow](#saving-configuration-flow) for the sequence diagram.

### 4a. Resetting folder overrides

The per-folder **"Reset overrides"** button (one per folder tab, top-right in the disclaimer banner) clears all of that folder's user overrides so the effective values fall back to org / LDX-sync / default.

**JS side (snyk-ls, already implemented):** clicking the button marks the folder *by its `folderPath`* (read from the hidden `folder_<index>_folderPath` input — not the index, which is compacted away during diffing). On save, `form-handler.applyFolderResets()` emits **flat `null`** for each of these 17 folder fields on that folder's entry in `folderConfigs`:

```
scan_automatic, scan_net_new,
severity_filter_critical, severity_filter_high, severity_filter_medium, severity_filter_low,
snyk_oss_enabled, snyk_code_enabled, snyk_iac_enabled, snyk_secrets_enabled,
issue_view_open_issues, issue_view_ignored_issues, risk_score_threshold,
preferred_org,
additional_parameters, additional_environment, scan_command_config
```

A reset is emitted **even if the folder has no other edits** — a reset-only folder still appears in the outbound `folderConfigs` keyed by `folderPath`. The JS deliberately emits **flat snake_case `null`**, not a `ConfigSetting` envelope; building the envelope is the IDE plugin's job (next paragraph).

**The IDE plugin MUST map a flat `null` folder field → `{value: null, changed: true}`** for that folder key when building `workspace/didChangeConfiguration`. This is the cross-IDE reset contract:

| Saved JSON folder field | IDE maps to `ConfigSetting` | LS effect |
|---|---|---|
| absent | omit from the map | no-op |
| present, non-null value | `{value: X, changed: true}` | set `user:folder:<path>:<key>` |
| **present, `null`** | `{value: null, changed: true}` | **Unset** the `user:folder:` override → fall back |

A plugin that treats a flat `null` as "absent" (e.g. nullable-type `?.let{}`, `node.isNull() → continue`, `.HasValue == false`) will silently drop the reset and the button will appear to do nothing. The null must survive deserialization (parse the raw JSON tree to distinguish present-with-null from absent when the data model can't) and reach the LS as `{value: null, changed: true}`. See the reset contract in [configuration.md](configuration.md#resetting-a-folder-override).

### 5. Authentication Flow

When the user clicks **Authenticate**, `features/authentication.js` calls `ConfigApp.ideBridge.login(authMethod, endpoint, insecure)`, which invokes **`window.__ideExecuteCommand__('snyk.login', [authMethod, endpoint, insecure])`**.
Expand Down
17 changes: 17 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,23 @@ The `Changed` field on `ConfigSetting` controls whether the LS processes a setti

This applies uniformly to both initialization (`InitializeSettings`) and runtime updates (`UpdateSettings`). The IDE is responsible for setting `Changed: true` only on settings the user explicitly configured.

### Resetting a folder override

A `ConfigSetting` value of `nil` combined with `Changed: true` is a **reset**: it tells the LS to `Unset` the `user:folder:<path>:<name>` override so the effective value falls back through the precedence chain (org / LDX-sync / GAF default). This is how the HTML settings dialog's per-folder "Reset overrides" button clears a folder's user edits.

| `ConfigSetting` on the wire | LS behaviour |
|---|---|
| absent from the map (`cs == nil`) | no-op |
| `{Changed: false}` (or omitted) | no-op (skipped, as above) |
| `{Changed: true, Value: <x>}` | set `user:folder:` override to `<x>` |
| `{Changed: true, Value: nil}` | **Unset** `user:folder:` override → fall back |

The null-reset applies to org-scope folder fields (those resolved through `applyGenericFolderOverrides`), which have a lower-precedence layer to fall back to. Most basic folder-native fields (`base_branch`, etc.) have no fallback layer, so a null value on them is treated as a no-op rather than a reset. **Exception:** `additional_parameters`, `additional_environment`, and `scan_command_config` are basic folder fields whose null-reset is honoured — their handlers in `applyBasicFolderFields` call `unsetFolderOverride` on `{Changed: true, Value: nil}`, clearing the user override.

`preferred_org` is the one exception among the basic-handled fields: it *does* have a fallback (the auto-determined LDX org, then the global org), so its null-reset is honoured. Resetting `preferred_org` Unsets **both** `user:folder:preferred_org` and `user:folder:org_set_by_user`, reverting the folder to its auto-determined / global org. See `applyPreferredOrg` in `internal/types/folder_config.go`.

Locked-field resets are rejected exactly like locked-field sets: `validateLockedFields` (`application/server/configuration.go`) strips a reset for a field locked by org policy before `ApplyLspUpdate` runs, so the locked value is preserved.

### IDE → LS Flow (didChangeConfiguration)

```mermaid
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/configuration/template/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,9 @@ <h2>Filters and views</h2>
<div class="tab-pane folder-pane" id="folder-pane-{{$index}}" role="tabpanel" aria-labelledby="folder-tab-{{$index}}">
<input type="hidden" name="folder_{{$index}}_folderPath" value="{{$folder.FolderPath}}">

<div class="info-box">
<div class="info-box folder-disclaimer">
<p>These settings override the {{$.FolderLabel | toLower}} defaults for this specific {{$.FolderLabel | toLower}}.</p>
<button type="button" class="reset-overrides-btn" data-folder-index="{{$index}}" title="Reset all overrides for this folder">Reset overrides</button>
</div>

<!-- Row: Scan Configuration + Filters and Views -->
Expand Down Expand Up @@ -690,7 +691,6 @@ <h2>Filters and views</h2>
</div>
</div>
</div>
<button type="button" class="reset-overrides-btn" data-folder-index="{{$index}}" title="Reset all overrides for this folder">Reset overrides</button>
</div>
{{end}}
{{end}}
Expand Down
16 changes: 16 additions & 0 deletions infrastructure/configuration/template/js/ide/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@
}
};

/**
* Show a confirmation dialog via the IDE native UI.
* In VSCode the sandboxed webview blocks window.confirm(), so this routes through
* the executeCommand bridge to show a native modal. Falls back to window.confirm()
* in IDEs that inject no bridge (JetBrains, Eclipse, etc.).
* @param {string} message - Message to display
* @param {function} callback - Called with true if confirmed, false if cancelled
*/
ideBridge.confirm = function (message, callback) {
if (typeof window.__ideExecuteCommand__ === "function") {
window.__ideExecuteCommand__("snyk.showConfirmationDialog", [message], callback);
} else if (typeof callback === "function") {
callback(window.confirm(message));
}
};

// Expose window-level functions for IDE to call

/**
Expand Down
Loading
Loading