Skip to content

Commit c501838

Browse files
committed
feat: DGP-387 - support --all-projects parameter
- changes to JSON output: - creates a slice of responses, one per file scanned, matching legacy CLI. - if there's a single file scanned or if run with --file, output is a dictionary {}. - adds 'displayTargetFile' for distinguishing separate projects. - tests written with AI and verified for correctness
1 parent 67ac150 commit c501838

File tree

5 files changed

+495
-149
lines changed

5 files changed

+495
-149
lines changed

internal/commands/ostest/ostest.go

Lines changed: 135 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
package ostest
1010

1111
import (
12+
"bytes"
1213
"context"
1314
"encoding/json"
1415
std_errors "errors"
1516
"fmt"
1617
"math"
1718
"os"
1819
"sort"
20+
"time"
1921

2022
"github.com/rs/zerolog"
2123
"github.com/snyk/go-application-framework/pkg/apiclients/testapi"
@@ -29,6 +31,7 @@ import (
2931
service "github.com/snyk/cli-extension-os-flows/internal/common"
3032
"github.com/snyk/cli-extension-os-flows/internal/errors"
3133
"github.com/snyk/cli-extension-os-flows/internal/flags"
34+
"github.com/snyk/cli-extension-os-flows/internal/legacy/definitions"
3235
"github.com/snyk/cli-extension-os-flows/internal/legacy/transform"
3336
"github.com/snyk/cli-extension-os-flows/internal/snykclient"
3437
)
@@ -57,6 +60,9 @@ const LogFieldCount = "count"
5760
// ErrNoSummaryData is returned when a test summary cannot be generated due to lack of data.
5861
var ErrNoSummaryData = std_errors.New("no summary data to create")
5962

63+
// PollInterval is the polling interval for the test API. It is exported to be configurable in tests.
64+
var PollInterval = 8 * time.Second
65+
6066
// RegisterWorkflows registers the "test" workflow.
6167
func RegisterWorkflows(e workflow.Engine) error {
6268
// Check if workflow already exists
@@ -140,12 +146,6 @@ func OSWorkflow(
140146

141147
// Unified test flow
142148

143-
filename := config.GetString(flags.FlagFile)
144-
if filename == "" {
145-
logger.Error().Msg("No file specified for testing")
146-
return nil, errFactory.NewMissingFilenameFlagError()
147-
}
148-
149149
var riskScorePtr *uint16
150150
if riskScoreThreshold >= math.MaxUint16 {
151151
// the API will enforce a range from the test spec
@@ -164,14 +164,29 @@ func OSWorkflow(
164164
severityThresholdPtr = &st
165165
}
166166

167-
return runUnifiedTestFlow(ctx, filename, riskScorePtr, severityThresholdPtr, ictx, config, orgID, errFactory, logger)
167+
return runUnifiedTestFlow(ctx, riskScorePtr, severityThresholdPtr, ictx, config, orgID, errFactory, logger)
168168
}
169169
}
170170

171+
// Create local policy only if risk score or severity threshold are specified.
172+
func createLocalPolicy(riskScoreThreshold *uint16, severityThreshold *testapi.Severity) *testapi.LocalPolicy {
173+
if riskScoreThreshold == nil && severityThreshold == nil {
174+
return nil
175+
}
176+
177+
localPolicy := &testapi.LocalPolicy{}
178+
if riskScoreThreshold != nil {
179+
localPolicy.RiskScoreThreshold = riskScoreThreshold
180+
}
181+
if severityThreshold != nil {
182+
localPolicy.SeverityThreshold = severityThreshold
183+
}
184+
return localPolicy
185+
}
186+
171187
// runUnifiedTestFlow handles the unified test API flow.
172188
func runUnifiedTestFlow(
173189
ctx context.Context,
174-
filename string,
175190
riskScoreThreshold *uint16,
176191
severityThreshold *testapi.Severity,
177192
ictx workflow.InvocationContext,
@@ -182,56 +197,90 @@ func runUnifiedTestFlow(
182197
) ([]workflow.Data, error) {
183198
logger.Info().Msg("Starting open source test")
184199

185-
// Create depgraph
186-
depGraph, err := createDepGraph(ictx)
200+
// Create depgraphs and get their associated target files
201+
depGraphs, displayTargetFiles, err := createDepGraphs(ictx)
187202
if err != nil {
188-
return nil, fmt.Errorf("failed to create depgraph: %w", err)
203+
return nil, err
189204
}
205+
var allFindings []definitions.LegacyVulnerabilityResponse
206+
var allSummaries []workflow.Data
190207

191-
// Create depgraph subject
192-
depGraphSubject := testapi.DepGraphSubjectCreate{
193-
Type: testapi.DepGraphSubjectCreateTypeDepGraph,
194-
DepGraph: depGraph,
195-
Locator: testapi.LocalPathLocator{
196-
Paths: []string{filename},
197-
Type: testapi.LocalPath,
198-
},
199-
}
208+
localPolicy := createLocalPolicy(riskScoreThreshold, severityThreshold)
200209

201-
// Create test subject with depgraph
202-
var subject testapi.TestSubjectCreate
203-
err = subject.FromDepGraphSubjectCreate(depGraphSubject)
204-
if err != nil {
205-
return nil, fmt.Errorf("failed to create test subject: %w", err)
206-
}
210+
for i, depGraph := range depGraphs {
211+
displayTargetFile := ""
212+
if i < len(displayTargetFiles) {
213+
displayTargetFile = displayTargetFiles[i]
214+
}
215+
216+
// Create depgraph subject
217+
depGraphSubject := testapi.DepGraphSubjectCreate{
218+
Type: testapi.DepGraphSubjectCreateTypeDepGraph,
219+
DepGraph: depGraph,
220+
Locator: testapi.LocalPathLocator{
221+
Paths: []string{displayTargetFile},
222+
Type: testapi.LocalPath,
223+
},
224+
}
225+
226+
// Create test subject with depgraph
227+
var subject testapi.TestSubjectCreate
228+
err = subject.FromDepGraphSubjectCreate(depGraphSubject)
229+
if err != nil {
230+
return nil, fmt.Errorf("failed to create test subject: %w", err)
231+
}
232+
233+
// Project name assigned as follows: --project-name || config project name || scannedProject?.depTree?.name
234+
// TODO: use project name from Config file
235+
config := ictx.GetConfiguration()
236+
projectName := config.GetString(flags.FlagProjectName)
237+
if projectName == "" && len(depGraph.Pkgs) > 0 {
238+
projectName = depGraph.Pkgs[0].Info.Name
239+
}
240+
241+
packageManager := depGraph.PkgManager.Name
242+
depCount := max(0, len(depGraph.Pkgs)-1)
207243

208-
// Only create local policy if risk score or severity threshold are specified
209-
var localPolicy *testapi.LocalPolicy
210-
if riskScoreThreshold != nil || severityThreshold != nil {
211-
localPolicy = &testapi.LocalPolicy{}
212-
if riskScoreThreshold != nil {
213-
localPolicy.RiskScoreThreshold = riskScoreThreshold
244+
// Run the test with the depgraph subject
245+
findings, summary, err := runTest(ctx, subject, projectName, packageManager, depCount, displayTargetFile, ictx, orgID, errFactory, logger, localPolicy)
246+
if err != nil {
247+
return nil, err
248+
}
249+
250+
if findings != nil {
251+
allFindings = append(allFindings, *findings)
214252
}
215-
if severityThreshold != nil {
216-
localPolicy.SeverityThreshold = severityThreshold
253+
if summary != nil {
254+
allSummaries = append(allSummaries, summary)
217255
}
218256
}
219257

220-
// Project name assigned as follows: --project-name || config project name || scannedProject?.depTree?.name
221-
// TODO: use project name from Config file
222-
// TODO: verify - depTree is a legacy depgraph concept that I don't see in cli-extension-dep-graph, but the name
223-
// appears to come from the first Pkg item.
224-
config := ictx.GetConfiguration()
225-
projectName := config.GetString(flags.FlagProjectName)
226-
if projectName == "" && len(depGraph.Pkgs) > 0 {
227-
projectName = depGraph.Pkgs[0].Info.Name
258+
var finalOutput []workflow.Data
259+
if len(allFindings) > 0 {
260+
var findingsData any
261+
if len(allFindings) == 1 {
262+
findingsData = allFindings[0]
263+
} else {
264+
findingsData = allFindings
265+
}
266+
267+
var buffer bytes.Buffer
268+
encoder := json.NewEncoder(&buffer)
269+
encoder.SetEscapeHTML(false)
270+
encoder.SetIndent("", " ")
271+
err := encoder.Encode(findingsData)
272+
if err != nil {
273+
return nil, errFactory.NewLegacyJSONTransformerError(fmt.Errorf("marshaling to json: %w", err))
274+
}
275+
// encoder.Encode adds a newline, which we trim to match Marshal's behavior.
276+
findingsBytes := bytes.TrimRight(buffer.Bytes(), "\n")
277+
278+
finalOutput = append(finalOutput, NewWorkflowData(ApplicationJSONContentType, findingsBytes))
228279
}
229280

230-
packageManager := depGraph.PkgManager.Name
231-
depCount := max(0, len(depGraph.Pkgs)-1)
281+
finalOutput = append(finalOutput, allSummaries...)
232282

233-
// Run the test with the depgraph subject
234-
return runTest(ctx, subject, projectName, packageManager, depCount, ictx, orgID, errFactory, logger, localPolicy)
283+
return finalOutput, nil
235284
}
236285

237286
// runTest executes the common test flow with the provided test subject.
@@ -241,12 +290,13 @@ func runTest(
241290
projectName string,
242291
packageManager string,
243292
depCount int,
293+
displayTargetFile string,
244294
ictx workflow.InvocationContext,
245295
orgID string,
246296
errFactory *errors.ErrorFactory,
247297
logger *zerolog.Logger,
248298
localPolicy *testapi.LocalPolicy,
249-
) ([]workflow.Data, error) {
299+
) (*definitions.LegacyVulnerabilityResponse, workflow.Data, error) {
250300
// Create Snyk client
251301
httpClient := ictx.GetNetworkAccess().GetHttpClient()
252302
snykClient := snykclient.NewSnykClient(httpClient, ictx.GetConfiguration().GetString(configuration.API_URL), orgID)
@@ -260,24 +310,25 @@ func runTest(
260310
// Create and execute test client
261311
testClient, err := testapi.NewTestClient(
262312
snykClient.GetAPIBaseURL(),
313+
testapi.WithPollInterval(PollInterval),
263314
testapi.WithCustomHTTPClient(snykClient.GetClient()),
264315
)
265316
if err != nil {
266-
return nil, fmt.Errorf("failed to create test client: %w", err)
317+
return nil, nil, fmt.Errorf("failed to create test client: %w", err)
267318
}
268319

269320
handle, err := testClient.StartTest(ctx, startParams)
270321
if err != nil {
271-
return nil, fmt.Errorf("failed to start test: %w", err)
322+
return nil, nil, fmt.Errorf("failed to start test: %w", err)
272323
}
273324

274325
if waitErr := handle.Wait(ctx); waitErr != nil {
275-
return nil, fmt.Errorf("test run failed: %w", waitErr)
326+
return nil, nil, fmt.Errorf("test run failed: %w", waitErr)
276327
}
277328

278329
finalResult := handle.Result()
279330
if finalResult == nil {
280-
return nil, fmt.Errorf("test completed but no result was returned")
331+
return nil, nil, fmt.Errorf("test completed but no result was returned")
281332
}
282333

283334
// Get findings for the test
@@ -300,7 +351,7 @@ func runTest(
300351
currentDir, err := os.Getwd()
301352
if err != nil {
302353
logger.Error().Err(err).Msg("Failed to get current working directory")
303-
return nil, fmt.Errorf("failed to get current working directory: %w", err)
354+
return nil, nil, fmt.Errorf("failed to get current working directory: %w", err)
304355
}
305356

306357
var uniqueCount int32
@@ -314,35 +365,32 @@ func runTest(
314365
}
315366
}
316367

317-
legacyJSON, err := transform.ConvertSnykSchemaFindingsToLegacyJSON(
368+
legacyVulnResponse, err := transform.ConvertSnykSchemaFindingsToLegacy(
318369
&transform.SnykSchemaToLegacyParams{
319-
Findings: findingsData,
320-
TestResult: finalResult,
321-
ProjectName: projectName,
322-
PackageManager: packageManager,
323-
CurrentDir: currentDir,
324-
UniqueCount: uniqueCount,
325-
DepCount: depCount,
326-
ErrFactory: errFactory,
327-
Logger: logger,
370+
Findings: findingsData,
371+
TestResult: finalResult,
372+
ProjectName: projectName,
373+
PackageManager: packageManager,
374+
CurrentDir: currentDir,
375+
UniqueCount: uniqueCount,
376+
DepCount: depCount,
377+
DisplayTargetFile: displayTargetFile,
378+
ErrFactory: errFactory,
379+
Logger: logger,
328380
})
329381
if err != nil {
330-
return nil, fmt.Errorf("error converting snyk schema findings to legacy json: %w", err)
382+
return nil, nil, fmt.Errorf("error converting snyk schema findings to legacy json: %w", err)
331383
}
332384

333-
legacyData := NewWorkflowData(ApplicationJSONContentType, legacyJSON)
334-
outputData := []workflow.Data{legacyData}
335-
336385
summaryData, err := NewSummaryData(finalResult, logger, currentDir)
337386
if err != nil {
338387
if !std_errors.Is(err, ErrNoSummaryData) {
339388
logger.Warn().Err(err).Msg("Failed to create test summary for exit code handling")
340389
}
341-
} else {
342-
outputData = append(outputData, summaryData)
390+
return legacyVulnResponse, nil, nil
343391
}
344392

345-
return outputData, nil
393+
return legacyVulnResponse, summaryData, nil
346394
}
347395

348396
// extractSeverityKeys returns a map of severity keys present in the summaries.
@@ -382,13 +430,13 @@ func NewSummaryData(testResult testapi.TestResult, logger *zerolog.Logger, path
382430
return nil, fmt.Errorf("test result missing summary information")
383431
}
384432

385-
severityKeys := extractSeverityKeys(rawSummary, effectiveSummary)
386-
387-
if len(severityKeys) == 0 && rawSummary.Count == 0 {
433+
if rawSummary.Count == 0 {
388434
logger.Debug().Msg("No findings in summary, skipping summary creation.")
389435
return nil, fmt.Errorf("no findings in summary: %w", ErrNoSummaryData)
390436
}
391437

438+
severityKeys := extractSeverityKeys(rawSummary, effectiveSummary)
439+
392440
var summaryResults []json_schemas.TestSummaryResult
393441
for severity := range severityKeys {
394442
total := getSeverityCount(rawSummary, severity)
@@ -444,31 +492,29 @@ func runReachabilityFlow(
444492
return sbomTestReachability(ctx, config, errFactory, ictx, logger, sbomPath, sourceCodePath)
445493
}
446494

447-
// createDepGraph creates a depgraph from the file parameter in the context.
448-
func createDepGraph(ictx workflow.InvocationContext) (testapi.IoSnykApiV1testdepgraphRequestDepGraph, error) {
449-
var contents []byte
450-
var err error
451-
495+
// createDepGraphs creates depgraphs from the file parameter in the context.
496+
func createDepGraphs(ictx workflow.InvocationContext) ([]testapi.IoSnykApiV1testdepgraphRequestDepGraph, []string, error) {
452497
depGraphResult, err := service.GetDepGraph(ictx)
453498
if err != nil {
454-
return testapi.IoSnykApiV1testdepgraphRequestDepGraph{}, fmt.Errorf("failed to get dependency graph: %w", err)
499+
return nil, nil, fmt.Errorf("failed to get dependency graph: %w", err)
455500
}
456501

457-
if len(depGraphResult.DepGraphBytes) > 1 {
458-
err = fmt.Errorf("multiple depgraphs found, but only one is currently supported")
459-
return testapi.IoSnykApiV1testdepgraphRequestDepGraph{}, err
502+
if len(depGraphResult.DepGraphBytes) == 0 {
503+
return nil, nil, fmt.Errorf("no dependency graphs found")
460504
}
461-
// TODO revisit handling multiple depgraphs
462-
contents = depGraphResult.DepGraphBytes[0]
463505

464-
var depGraphStruct testapi.IoSnykApiV1testdepgraphRequestDepGraph
465-
err = json.Unmarshal(contents, &depGraphStruct)
466-
if err != nil {
467-
return testapi.IoSnykApiV1testdepgraphRequestDepGraph{},
468-
fmt.Errorf("unmarshaling depGraph from args failed: %w", err)
506+
depGraphs := make([]testapi.IoSnykApiV1testdepgraphRequestDepGraph, len(depGraphResult.DepGraphBytes))
507+
for i, depGraphBytes := range depGraphResult.DepGraphBytes {
508+
var depGraphStruct testapi.IoSnykApiV1testdepgraphRequestDepGraph
509+
err = json.Unmarshal(depGraphBytes, &depGraphStruct)
510+
if err != nil {
511+
return nil, nil,
512+
fmt.Errorf("unmarshaling depGraph from args failed: %w", err)
513+
}
514+
depGraphs[i] = depGraphStruct
469515
}
470516

471-
return depGraphStruct, nil
517+
return depGraphs, depGraphResult.DisplayTargetFiles, nil
472518
}
473519

474520
// NewWorkflowData creates a workflow.Data object with the given content type and data.

internal/commands/ostest/ostest_summary_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func Test_newSummaryData(t *testing.T) {
121121
data, err := ostest.NewSummaryData(testResult, &logger, path)
122122
assert.Nil(t, data)
123123
assert.True(t, errors.Is(err, ostest.ErrNoSummaryData))
124-
assert.ErrorContains(t, err, "no summary results to process")
124+
assert.ErrorContains(t, err, "no findings in summary")
125125
})
126126

127127
t.Run("one critical finding should create summary data, implying exit code 1", func(t *testing.T) {

0 commit comments

Comments
 (0)