diff --git a/.github/workflows/Semgrep.yml b/.github/workflows/Semgrep.yml index d2614fa07..b08eda5ac 100644 --- a/.github/workflows/Semgrep.yml +++ b/.github/workflows/Semgrep.yml @@ -26,8 +26,12 @@ jobs: runs-on: ubuntu-latest container: - # A Docker image with Semgrep installed. Do not change this. - image: returntocorp/semgrep + # A Docker image with Semgrep installed. + # Pinned to >=1.164.0: the old `returntocorp/semgrep:latest` (semgrep 1.162.0) + # has a bug where `semgrep ci --sarif` counts nosemgrep-suppressed findings as + # blocking and exits non-zero (fixed in 1.164.0), so inline `// nosemgrep` + # suppressions were ignored. See https://semgrep.dev/docs CHANGELOG v1.164.0. + image: semgrep/semgrep:1.164.0 # Skip any PR created by dependabot to avoid permission issues: if: (github.actor != 'dependabot[bot]') diff --git a/packages/cli-command/package.json b/packages/cli-command/package.json index 8d1c8e937..92abf32f6 100644 --- a/packages/cli-command/package.json +++ b/packages/cli-command/package.json @@ -27,6 +27,7 @@ ".": "./dist/index.js", "./flags": "./dist/flags.js", "./utils": "./dist/utils.js", + "./smartsnap": "./dist/smartsnap.js", "./test/helpers": "./test/helpers.js" }, "scripts": { @@ -38,6 +39,11 @@ "dependencies": { "@percy/config": "1.32.0-beta.6", "@percy/core": "1.32.0-beta.6", - "@percy/logger": "1.32.0-beta.6" + "@percy/logger": "1.32.0-beta.6", + "glob-to-regexp": "^0.4.1", + "stream-json": "^1.8.0" + }, + "optionalDependencies": { + "snyk-nodejs-lockfile-parser": "2.7.1" } } diff --git a/packages/cli-command/src/graphTrace.js b/packages/cli-command/src/graphTrace.js new file mode 100644 index 000000000..f869286c8 --- /dev/null +++ b/packages/cli-command/src/graphTrace.js @@ -0,0 +1,154 @@ +import fs from 'fs'; +import path from 'path'; +import url from 'url'; + +// Template resolution mirrors core/utils.js's secretPatterns.yml lookup: +// resolves relative to this file's URL so it works under src/ (dev) and +// dist/ (installed) without bundler help. The .html file is copied alongside +// by babel's copyFiles when cli-command is built. +const TEMPLATE_PATH = path.resolve(url.fileURLToPath(import.meta.url), '../graphTraceTemplate.html'); + +// Maps a (raw kind, changed) pair to the kind value the template expects: +// 'package' | 'component' | 'story' | 'is_relevant'. `changed: true` wins +// over the underlying kind so any node touched in the diff renders purple. +function templateKindOf(v) { + if (v.changed) return 'is_relevant'; + switch (v.kind) { + case 'dependency': return 'package'; + case 'component': return 'component'; + case 'story': return 'story'; + default: return 'component'; + } +} + +// Sort order within a column: packages left, components middle, stories right. +// `is_relevant` shares rank with components so a changed node doesn't jump +// out of its own group — it just recolors. +const KIND_RANK = { package: 0, component: 1, is_relevant: 1, story: 2 }; + +// Layout algorithm (ported from the original Ruby renderer): +// 1. col = longest-path depth reaching the vertex (read from the +// transitive-closure triples the API sends), with dependencies pinned +// to col 0. +// 2. Propagate over edges so col[target] > col[source]. Bounded loop +// guards against degenerate inputs. +// 3. Stories pushed past the rightmost non-story column. +// 4. Within each column, sort by (kind-rank, name) and assign row. +function computeLayout(rawVertices, edges, transitiveClosure) { + const n = rawVertices.length; + const vertices = rawVertices.map((v, i) => ({ + index: i, + name: v.file_path, + kind: v.kind, + changed: !!v.changed, + row: 0, + col: 0 + })); + + // 1. Seed col from incoming transitive-closure lengths. + const incomingMax = new Array(n).fill(0); + for (const triple of transitiveClosure) { + const [u, v, val] = triple; + if (u === v || val <= 0) continue; + if (v < 0 || v >= n) continue; + if (val > incomingMax[v]) incomingMax[v] = val; + } + for (let i = 0; i < n; i++) { + vertices[i].col = vertices[i].kind === 'dependency' ? 0 : incomingMax[i] + 1; + } + + // 2. Propagate edge constraint. n+2 iterations is enough for any DAG + // and bounds the work on accidentally-cyclic input. + const iterations = n + 2; + for (let iter = 0; iter < iterations; iter++) { + let changed = false; + for (const [s, t] of edges) { + if (s < 0 || s >= n || t < 0 || t >= n) continue; + if (vertices[s].col < vertices[t].col) continue; + vertices[t].col = vertices[s].col + 1; + changed = true; + } + if (!changed) break; + } + + // 3. Stories rightmost. Two passes: max across non-stories first, then + // push every story past that boundary. Folding into one loop would let + // stories visited before the last non-story keep a stale max. + let furthestNonStory = 0; + for (const v of vertices) { + if (v.kind === 'story') continue; + if (v.col > furthestNonStory) furthestNonStory = v.col; + } + for (const v of vertices) { + if (v.kind !== 'story') continue; + if (v.col < furthestNonStory + 1) v.col = furthestNonStory + 1; + } + + // 4. Group by column, sort by (kind-rank, name), assign row. + const groups = new Map(); + for (const v of vertices) { + let list = groups.get(v.col); + if (!list) groups.set(v.col, list = []); + list.push(v); + } + const rankOf = v => { + const r = KIND_RANK[templateKindOf(v)]; + /* istanbul ignore next: templateKindOf always returns a kind present in + KIND_RANK, so the `=== undefined` fallback is defensive */ + return r === undefined ? 99 : r; + }; + for (const list of groups.values()) { + list.sort((a, b) => { + const ra = rankOf(a); + const rb = rankOf(b); + if (ra !== rb) return ra - rb; + // Byte-wise compare on name to match Ruby's String#<=> behaviour. + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + return 0; + }); + list.forEach((v, row) => { v.row = row; }); + } + + // 5. Final shape the template consumes: drop `changed`, fold it into kind. + return vertices.map(v => ({ + index: v.index, + name: v.name, + row: v.row, + col: v.col, + kind: templateKindOf(v) + })); +} + +// Escapes characters that have meaning inside a `; `` cover HTML comment confusion; U+2028 +// and U+2029 are valid JSON but illegal in JS string literals pre-ES2019 and +// have historically been XSS sinks. +const LS = String.fromCharCode(0x2028); +const PS = String.fromCharCode(0x2029); +function safeJson(obj) { + return JSON.stringify(obj) + .replace(/<\//g, '<\\/') + .replace(/`; + + function hostileLine() { + return embeddedJson(renderGraphTraceHtml({ + vertices: [{ kind: 'component', file_path: hostile }], + edges: [], + transitiveClosureMatrixSparse: [] + }), 'vertices'); + } + + it('escapes " { + let line = hostileLine(); + expect(line).not.toContain(''); + expect(line).toContain('<\\/script>'); + }); + + it('escapes HTML comment open and close markers', () => { + let line = hostileLine(); + expect(line).toContain('<\\!--'); + expect(line).toContain('--\\>'); + }); + + it('escapes U+2028 and U+2029 line/paragraph separators', () => { + let line = hostileLine(); + expect(line).not.toContain(LS); + expect(line).not.toContain(PS); + expect(line).toContain('\\u2028'); + expect(line).toContain('\\u2029'); + }); + + it('escapes only the dangerous sequences, leaving the payload intact', () => { + // The output is embedded in a