Skip to content

Commit

Permalink
feat: IaC panel in HTML [IDE-289] (#616)
Browse files Browse the repository at this point in the history
  • Loading branch information
Cata authored Aug 12, 2024
1 parent ca51b7b commit 72b5546
Show file tree
Hide file tree
Showing 11 changed files with 550 additions and 21 deletions.
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
"go.buildOnSave": "workspace",
"go.lintTool": "golangci-lint",
"go.formatTool": "goimports",
"go.formatFlags": ["-w", "-s"],
"go.formatFlags": [
"-w",
"-s"
],
"gopls": {
"formatting.local": "github.com/snyk/snyk-ls"
},
"go.toolsManagement.autoUpdate": true,
"html.format.unformattedContentDelimiter": "<!--noformat-->"
}
72 changes: 72 additions & 0 deletions docs/ui-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ sequenceDiagram
2. **Send HTML Template**: The generated HTML template is sent to the IDEs. This template includes the structure of the UI components but lacks specific styling.
3. **Injected IDE-specific CSS**: Each IDE injects its own CSS to style the HTML template according to its theming and design guidelines. This allows the same HTML structure to be visually consistent with the rest of the IDE.

### Using `//go:embed` for CSS and HTML Files

This directive loads static assets like CSS and HTML files directly into our application. This is particularly useful for embedding resources such as templates and stylesheets that are needed for rendering the UI components.

```go
//go:embed template/index.html
var detailsHtmlTemplate string

//go:embed template/styles.css
var stylesCSS string
```

The Go build system will recognize the directives and arrange for the declared variable to be populated with the matching files from the file system.

### Adding or Modifying UI Components

#### Snyk Code Suggestion Panel
Expand Down Expand Up @@ -89,3 +103,61 @@ func getCodeDetailsHtml(issue snyk.Issue) string {
- HTML Rendering: [JCEFDescriptionPanel.kt](https://github.com/snyk/snyk-intellij-plugin/blob/2581e2dc2e8722a960d0f0095377b7912a3789fe/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/JCEFDescriptionPanel.kt)

<img src="https://github.com/snyk/snyk-ls/assets/1948377/01644706-f884-48cd-b98d-24868030677c" width="640">

4. **Handle Nonce and IDE-Specific Styles**

When dealing with Content Security Policies (CSP) in the HTML generated by the Language Server, it’s important to correctly handle `nonce` attributes for both styles and scripts to ensure they are applied securely.

- **Language Server**: Ensure the HTML template includes `nonce` placeholders that will be replaced by dynamically generated nonces. For example:

```html

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'self' 'nonce-{{.Nonce}}' 'nonce-ideNonce'; script-src 'nonce-{{.Nonce}}';">
<!--noformat-->
<style nonce="{{.Nonce}}">
{{.Styles}}
</style>
<!--noformat-->
<style nonce="ideNonce" data-ide-style></style>
</head>
```

- `{{.Nonce}}`: This is a dynamically generated **nonce** passed from, for example, `infrastructure/iac/iac_html.go`.
- `{{.Styles}}`: This is where the styles defined in the Language Server are injected, also passed via `infrastructure/iac/iac_html.go`.
- `ideNonce`: This placeholder will be replaced by the IDE with its own dynamically generated nonce for IDE-specific styles.

- **IDE (e.g., VSCode)**: Replace the nonce placeholder in the HTML with the actual nonce generated by the IDE, and inject IDE-specific styles:

```typescript
private getHtmlFromLanguageServer(html: string): string {
const nonce = getNonce();
const ideStylePath = vscode.Uri.joinPath(
vscode.Uri.file(this.context.extensionPath),
'media',
'views',
'snykCode',
'suggestion',
'suggestionLS.css',
);

const ideStyle = readFileSync(ideStylePath.fsPath, 'utf8');
html = html.replace(/nonce-ideNonce/g, `nonce-${nonce}`); // Replace the placeholder with IDE nonce
html = html.replace(
'<style nonce="ideNonce" data-ide-style></style>',
`<style nonce="${nonce}">${ideStyle}</style>`,
);

return html;
}
```

### Final Workflow

1. **Generate HTML Template with Nonce**: The Language Server generates an HTML template, including placeholders for
`nonce` attributes.
2. **Send HTML Template to IDE**: The IDE receives the template and prepares to render it.
3. **Replace Nonce and Inject Styles**: The IDE replaces the nonce placeholders with actual nonces and injects any IDE-specific styles.
20 changes: 11 additions & 9 deletions domain/ide/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ package converter

import (
"fmt"
"github.com/snyk/snyk-ls/internal/product"
"regexp"
"strconv"

"github.com/snyk/snyk-ls/internal/product"

sglsp "github.com/sourcegraph/go-lsp"

"github.com/snyk/snyk-ls/domain/ide/hover"
Expand Down Expand Up @@ -273,14 +274,15 @@ func getIacIssue(issue snyk.Issue) types.ScanIssue {
FilePath: issue.AffectedFilePath,
Range: ToRange(issue.Range),
AdditionalData: types.IacIssueData{
PublicId: additionalData.PublicId,
Documentation: additionalData.Documentation,
LineNumber: additionalData.LineNumber,
Issue: additionalData.Issue,
Impact: additionalData.Impact,
Resolve: additionalData.Resolve,
Path: additionalData.Path,
References: additionalData.References,
PublicId: additionalData.PublicId,
Documentation: additionalData.Documentation,
LineNumber: additionalData.LineNumber,
Issue: additionalData.Issue,
Impact: additionalData.Impact,
Resolve: additionalData.Resolve,
Path: additionalData.Path,
References: additionalData.References,
CustomUIContent: additionalData.CustomUIContent,
},
}

Expand Down
2 changes: 2 additions & 0 deletions domain/snyk/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ type IaCIssueData struct {
Path []string `json:"path"`
// References: List of reference URLs
References []string `json:"references,omitempty"`
// CustomUIContent: IaC HTML template
CustomUIContent string `json:"customUIContent"`
}

func (i IaCIssueData) IsFixable() bool {
Expand Down
15 changes: 13 additions & 2 deletions infrastructure/iac/iac.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ func (iac *Scanner) toIssue(affectedFilePath string, issue iacIssue, fileContent
return snyk.Issue{}, errors.Wrap(err, "unable to create IaC issue additional data")
}

return snyk.Issue{
iacIssue := snyk.Issue{
ID: issue.PublicID,
Range: snyk.Range{
Start: snyk.Position{Line: issue.LineNumber, Character: rangeStart},
Expand All @@ -378,7 +378,18 @@ func (iac *Scanner) toIssue(affectedFilePath string, issue iacIssue, fileContent
IssueType: snyk.InfrastructureIssue,
CodeActions: []snyk.CodeAction{action},
AdditionalData: additionalData,
}, nil
}

htmlRender, err := NewIacHtmlRenderer(iac.c)
if err != nil {
iac.c.Logger().Err(err).Msg("Cannot create IaC HTML render")
return snyk.Issue{}, err
}

additionalData.CustomUIContent = htmlRender.getCustomUIContent(iacIssue)
iacIssue.AdditionalData = additionalData

return iacIssue, nil
}

func (iac *Scanner) toAdditionalData(affectedFilePath string, issue iacIssue) (snyk.IaCIssueData, error) {
Expand Down
99 changes: 99 additions & 0 deletions infrastructure/iac/iac_html.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* © 2024 Snyk Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package iac

import (
"bytes"
_ "embed"
"html/template"
"strings"

"github.com/snyk/snyk-ls/application/config"
"github.com/snyk/snyk-ls/domain/snyk"
"github.com/snyk/snyk-ls/internal/html"
"github.com/snyk/snyk-ls/internal/product"
)

type IacHtmlRender struct {
Config *config.Config
GlobalTemplate *template.Template
}

type TemplateData struct {
Styles template.CSS
Issue snyk.Issue
SeverityIcon template.HTML
Description template.HTML
Remediation template.HTML
Path template.HTML
Nonce string
}

//go:embed template/index.html
var detailsHtmlTemplate string

//go:embed template/styles.css
var stylesCSS string

func NewIacHtmlRenderer(cfg *config.Config) (*IacHtmlRender, error) {
tmp, err := template.New(string(product.ProductInfrastructureAsCode)).Parse(detailsHtmlTemplate)
if err != nil {
cfg.Logger().Error().Msgf("Failed to parse IaC template: %s", err)
return nil, err
}

return &IacHtmlRender{
Config: cfg,
GlobalTemplate: tmp,
}, nil
}

func getStyles() template.CSS {
return template.CSS(stylesCSS)
}

// Function to get the rendered HTML with issue details and CSS
func (service *IacHtmlRender) getCustomUIContent(issue snyk.Issue) string {
var htmlTemplate bytes.Buffer

nonce, err := html.GenerateSecurityNonce()
if err != nil {
service.Config.Logger().Warn().Msgf("Failed to generate nonce: %s", err)
return ""
}

data := TemplateData{
Styles: getStyles(),
Issue: issue,
SeverityIcon: html.SeverityIcon(issue),
Description: html.MarkdownToHTML(issue.Message),
Remediation: html.MarkdownToHTML(issue.AdditionalData.(snyk.IaCIssueData).Resolve),
Path: formatPath(issue.AdditionalData.(snyk.IaCIssueData).Path),
Nonce: nonce,
}

err = service.GlobalTemplate.Execute(&htmlTemplate, data)
if err != nil {
service.Config.Logger().Error().Msgf("Failed to execute IaC template: %s", err)
}

return htmlTemplate.String()
}

func formatPath(path []string) template.HTML {
return template.HTML(strings.Join(path, " > "))
}
83 changes: 83 additions & 0 deletions infrastructure/iac/iac_html_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package iac

import (
"net/url"
"testing"

"github.com/stretchr/testify/assert"

"github.com/snyk/snyk-ls/application/config"
"github.com/snyk/snyk-ls/domain/snyk"
)

func Test_IaC_Html_getIacHtml(t *testing.T) {
cfg := &config.Config{}

// Initialize the IaC service
service, _ := NewIacHtmlRenderer(cfg)
iacPanelHtml := service.getCustomUIContent(createIacIssueSample())

// assert
assert.Contains(t, iacPanelHtml, "<!DOCTYPE html>", "HTML should contain the doctype declaration")
assert.Contains(t, iacPanelHtml, "<meta http-equiv=\"Content-Security-Policy\"", "HTML should contain the CSP meta tag")
assert.Contains(t, iacPanelHtml, "nonce=", "HTML should include a nonce")
assert.Contains(t, iacPanelHtml, "<style nonce=", "Style tag should contain the nonce attribute")

// Check for the presence of specific issue details
assert.Contains(t, iacPanelHtml, "Role or ClusterRole with too wide permissions", "HTML should contain the issue title")
assert.Contains(t, iacPanelHtml, "The role uses wildcards, which grant the role permissions to the whole cluster", "HTML should contain the issue description")
assert.Contains(t, iacPanelHtml, "Set only the necessary permissions required", "HTML should contain the remediation instructions")

// Path is correctly formatted
assert.Contains(t, iacPanelHtml, "[DocId: 5] > rules[0] > verbs", "HTML should contain the path to the affected file")

// Issue ID is present and linked correctly
assert.Contains(t, iacPanelHtml, `href="https://security.snyk.io/rules/cloud/SNYK-CC-K8S-44">SNYK-CC-K8S-44</a>`, "HTML should contain a link to the issue documentation")

// Severity icon is rendered
assert.Contains(t, iacPanelHtml, `<div class="severity-container">`, "HTML should contain the severity icon container")

// Reference section
assert.Contains(t, iacPanelHtml, `<a class="styled-link" target="_blank" rel="noopener noreferrer" href="https://kubernetes.io/docs/reference/access-authn-authz/rbac/">https://kubernetes.io/docs/reference/access-authn-authz/rbac/</a>`, "HTML should contain the first reference")
assert.Contains(t, iacPanelHtml, `<a class="styled-link" target="_blank" rel="noopener noreferrer" href="https://kubernetes.io/docs/reference/access-authn-authz/rbac/#role-and-clusterrole">https://kubernetes.io/docs/reference/access-authn-authz/rbac/#role-and-clusterrole</a>`, "HTML should contain the second reference")
}

func createIacIssueSample() snyk.Issue {
issueURL, _ := url.Parse("https://security.snyk.io/rules/cloud/SNYK-CC-K8S-44")

return snyk.Issue{
ID: "SNYK-CC-K8S-44",
Severity: 1,
IssueType: 5,
IsIgnored: false,
IgnoreDetails: nil, // No ignore details provided
Range: snyk.Range{
Start: snyk.Position{Line: 141, Character: 2},
End: snyk.Position{Line: 141, Character: 14},
},
Message: "The role uses wildcards, which grant the role permissions to the whole cluster (Snyk)",
FormattedMessage: "\n### SNYK-CC-K8S-44: Role or ClusterRole with too wide permissions\n\n**Issue:** The role uses wildcards, which grant the role permissions to the whole cluster\n\n**Impact:** The use of wildcard rights grants is likely to provide excessive rights to the Kubernetes API. For a ClusterRole this would be considered high severity.\n\n**Resolve:** Set only the necessary permissions required\n",
AffectedFilePath: "/Users/cata/git/playground/dex/examples/k8s/dex.yaml",
Product: "Snyk IaC",
References: nil, // No references provided
IssueDescriptionURL: issueURL,
CodelensCommands: nil,
Ecosystem: "",
CWEs: nil,
CVEs: nil,
Fingerprint: "",
GlobalIdentity: "",
AdditionalData: snyk.IaCIssueData{
Key: "6bd172724ee6100d2d062221628921b6",
Title: "Role or ClusterRole with too wide permissions",
PublicId: "SNYK-CC-K8S-44",
Documentation: "https://security.snyk.io/rules/cloud/SNYK-CC-K8S-44",
LineNumber: 141,
Issue: "The role uses wildcards, which grant the role permissions to the whole cluster",
Impact: "The use of wildcard rights grants is likely to provide excessive rights to the Kubernetes API. For a ClusterRole this would be considered high severity.",
Resolve: "Set only the necessary permissions required",
Path: []string{"[DocId: 5]", "rules[0]", "verbs"},
References: []string{"https://kubernetes.io/docs/reference/access-authn-authz/rbac/", "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#role-and-clusterrole"},
},
}
}
32 changes: 31 additions & 1 deletion infrastructure/iac/iac_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,37 @@ func Test_createIssueDataForCustomUI_SuccessfullyParses(t *testing.T) {

assert.NoError(t, err)
assert.NotNil(t, issue.AdditionalData)
assert.Equal(t, expectedAdditionalData, issue.AdditionalData)

actualAdditionalData, ok := issue.AdditionalData.(snyk.IaCIssueData)
assert.True(t, ok)

assert.Equal(t, expectedAdditionalData.Key, actualAdditionalData.Key)
assert.Equal(t, expectedAdditionalData.Title, actualAdditionalData.Title)
assert.Equal(t, expectedAdditionalData.PublicId, actualAdditionalData.PublicId)
assert.Equal(t, expectedAdditionalData.Documentation, actualAdditionalData.Documentation)
assert.Equal(t, expectedAdditionalData.LineNumber, actualAdditionalData.LineNumber)
assert.Equal(t, expectedAdditionalData.Issue, actualAdditionalData.Issue)
assert.Equal(t, expectedAdditionalData.Impact, actualAdditionalData.Impact)
assert.Equal(t, expectedAdditionalData.Resolve, actualAdditionalData.Resolve)
assert.Equal(t, expectedAdditionalData.References, actualAdditionalData.References)

assert.NotEmpty(t, actualAdditionalData.CustomUIContent, "Details field should not be empty")
assert.Contains(t, actualAdditionalData.CustomUIContent, "<!DOCTYPE html>", "Details should contain HTML doctype declaration")
assert.Contains(t, actualAdditionalData.CustomUIContent, "PublicID", "Details should contain the PublicID")
}

func Test_toIssue_issueHasHtmlTemplate(t *testing.T) {
c := testutil.UnitTest(t)
sampleIssue := sampleIssue()
scanner := New(c, performance.NewInstrumentor(), error_reporting.NewTestErrorReporter(), cli.NewTestExecutor())
issue, err := scanner.toIssue("test.yml", sampleIssue, "")

assert.NoError(t, err)

// Assert the Details field contains the HTML template and expected content
additionalData := issue.AdditionalData.(snyk.IaCIssueData)
assert.NotEmpty(t, additionalData.CustomUIContent, "HTML Details should not be empty")
assert.Contains(t, additionalData.CustomUIContent, "PublicID", "HTML should contain the PublicID")
}

func Test_getIssueId(t *testing.T) {
Expand Down
Loading

0 comments on commit 72b5546

Please sign in to comment.