@scalequality/check 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,8 +23,9 @@ and **Turn on continuous measurement**. Store the token as a CI secret named
23
23
  | `--json` | — | off | Print the raw result as JSON |
24
24
  | `--exclude <path>` | — | — | Exclude a generated file/directory by literal checkout-relative path; repeatable |
25
25
  | `--dry-run` | — | off | Measure locally and validate the package without uploading; no token needed |
26
+ | `--artifact <path>` | `SQ_ARTIFACTS` | — | Scan a binary this job built for its components, licenses and CVEs (needs trivy); repeatable |
26
27
 
27
- Exit codes: `0` passed (or track-only), `1` below the cutoff, `2` error.
28
+ Exit codes: `0` passed (or track-only), `1` below the cutoff or a license blocked by your policy, `2` error.
28
29
 
29
30
  ## CI examples
30
31
 
@@ -201,3 +202,26 @@ A file that changes or cannot be copied stops submission before any upload.
201
202
  The existing 60 MiB compressed upload limit, Git durability calculation and
202
203
  maturity gates are unchanged. This release requires the matching server reader
203
204
  that accepts the complete staged report index.
205
+
206
+ ## 0.5.4: open source licenses in the pull request, and the binaries you build
207
+
208
+ **License gate.** Each run sends where it happened (GitHub Actions, GitLab CI,
209
+ Azure Pipelines or Bitbucket Pipelines: the pull request's base and head
210
+ branches and its number). ScaleQuality compares the run's dependencies with the
211
+ base branch and lists every new dependency whose license your company policy
212
+ forbids, in the log and, on GitHub Actions, in the job summary. It is a warning
213
+ unless your organization turned on "Block the pull request" in Compliance ›
214
+ Licenses; then the step exits 1 until the package is removed or an Owner or Admin
215
+ accepts it with an exception.
216
+
217
+ **`--artifact <path>`** (repeatable, or `SQ_ARTIFACTS=a.jar,b.war`). Scans a
218
+ binary this job built (jar, war, ear, Go and Rust binaries with build metadata,
219
+ .NET, wheels) with `trivy rootfs`, so the components bundled inside it, their
220
+ licenses and their CVEs join the measurement. Needs trivy on PATH, like
221
+ `--image`; without it the step says so and the gate still runs.
222
+
223
+ ```yaml
224
+ - uses: aquasecurity/setup-trivy@v0.2.0
225
+ - run: npx @scalequality/check --artifact target/app.war
226
+ env: { SQ_TOKEN: ${{ secrets.SQ_TOKEN }}, SQ_PROJECT: ${{ vars.SQ_PROJECT }} }
227
+ ```
package/bin/check.mjs CHANGED
@@ -5,10 +5,11 @@
5
5
  // build. Pure Node 18+ (global fetch/FormData/Blob), no npm dependencies.
6
6
 
7
7
  import { spawnSync } from "node:child_process"
8
- import { readFileSync } from "node:fs"
8
+ import { readFileSync, existsSync, appendFileSync } from "node:fs"
9
9
  import { prepareCheckoutArchive, normalizeExcludes } from "./archive.mjs"
10
10
  import { measureDurability } from "./durability.mjs"
11
11
  import { stageCoverageReports } from "./coverage.mjs"
12
+ import { ciContext } from "./ci.mjs"
12
13
 
13
14
  const args = process.argv.slice(2)
14
15
  const flag = (name) => {
@@ -30,13 +31,21 @@ Options:
30
31
  --image <ref> Also scan the container image this job just built, e.g.
31
32
  myapp:\${{ github.sha }}. Needs trivy on PATH; skipped with a
32
33
  note if it is missing, never fails the build for it.
34
+ --artifact <p> Also scan a binary this job built (jar, war, ear, Go or Rust
35
+ binary, .NET, wheel) for its components, their licenses and
36
+ CVEs; repeatable. Needs trivy on PATH; skipped with a note if
37
+ it is missing, never fails the build for it.
33
38
  --timeout <s> Max seconds to wait for the measurement (default 600)
34
39
  --exclude <p> Exclude a generated path relative to the checkout; repeatable
35
40
  --dry-run Measure locally and check the package; do not upload (no token needed)
36
41
  --json Print the raw result as JSON
37
42
  -h, --help Show this help
38
43
 
39
- Exit codes: 0 passed (or track-only), 1 below the cutoff, 2 error.`)
44
+ Pull requests that add a dependency whose license your company policy forbids
45
+ are listed in the log and the job summary; the check fails for it only where
46
+ your organization turned blocking on.
47
+
48
+ Exit codes: 0 passed (or track-only), 1 below the cutoff or a blocked license, 2 error.`)
40
49
  process.exit(0)
41
50
  }
42
51
 
@@ -46,6 +55,10 @@ const host = (flag("--host") || process.env.SQ_HOST || "https://app.scalequality
46
55
  const jsonOut = has("--json")
47
56
  const dryRun = has("--dry-run")
48
57
  const image = flag("--image") || process.env.SQ_IMAGE
58
+ const artifacts = [
59
+ ...args.flatMap((arg, i) => (arg === "--artifact" && args[i + 1] ? [args[i + 1]] : [])),
60
+ ...(process.env.SQ_ARTIFACTS ? process.env.SQ_ARTIFACTS.split(",").map((s) => s.trim()).filter(Boolean) : []),
61
+ ]
49
62
  const timeoutMs = Math.max(30, Number(flag("--timeout") || 600)) * 1000
50
63
 
51
64
  const base = `${host}/api/insights/continuous`
@@ -127,6 +140,80 @@ function slimReport(stdout, ref) {
127
140
  return json
128
141
  }
129
142
 
143
+ /**
144
+ * Binaries this job built. A component bundled inside a jar or a Go binary is
145
+ * shipped like any dependency, but no lockfile lists it: the only place it can
146
+ * be read is the artifact, and the artifact only exists here. Same rules as the
147
+ * image: no trivy, a note and the gate still runs; a path that is not there is
148
+ * said, never skipped silently.
149
+ */
150
+ function scanArtifacts() {
151
+ if (!artifacts.length) return null
152
+ const probe = spawnSync("trivy", ["--version"], { encoding: "utf8" })
153
+ if (probe.error || probe.status !== 0) {
154
+ log("ScaleQuality: --artifact was given but trivy is not on PATH, so the artifact scan was skipped.")
155
+ log("ScaleQuality: add it before this step, e.g. `- uses: aquasecurity/setup-trivy@v0.2.0`.")
156
+ return null
157
+ }
158
+ const reports = []
159
+ for (const path of artifacts.slice(0, 20)) {
160
+ if (!existsSync(path)) { log(`ScaleQuality: artifact ${path} does not exist, skipped.`); continue }
161
+ const r = spawnSync("trivy", [
162
+ "rootfs", "--format", "json", "--quiet",
163
+ "--scanners", "vuln,license", "--list-all-pkgs",
164
+ "--severity", "CRITICAL,HIGH,MEDIUM",
165
+ path,
166
+ ], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 })
167
+ if (r.error || r.status !== 0 || !r.stdout) { log(`ScaleQuality: could not scan ${path}, continuing without it.`); continue }
168
+ const report = slimArtifact(r.stdout, path)
169
+ if (report) reports.push({ name: path.split("/").pop() || path, report })
170
+ }
171
+ return reports.length ? JSON.stringify(reports) : null
172
+ }
173
+
174
+ /** Components with their licenses and the findings, nothing else: the server reads these fields. */
175
+ function slimArtifact(stdout, path) {
176
+ let d
177
+ try { d = JSON.parse(stdout) } catch { return null }
178
+ let packages = 0, vulns = 0
179
+ const out = {
180
+ ArtifactName: d.ArtifactName || path,
181
+ Results: (d.Results || []).map((res) => ({
182
+ Target: res.Target,
183
+ Class: res.Class,
184
+ Type: res.Type,
185
+ Packages: (res.Packages || []).map((p) => {
186
+ packages++
187
+ return { Name: p.Name, Version: p.Version, Licenses: Array.isArray(p.Licenses) ? p.Licenses : undefined, Identifier: p.Identifier?.PURL ? { PURL: p.Identifier.PURL } : undefined }
188
+ }),
189
+ Vulnerabilities: (res.Vulnerabilities || []).map((v) => {
190
+ vulns++
191
+ return { VulnerabilityID: v.VulnerabilityID, PkgName: v.PkgName, InstalledVersion: v.InstalledVersion, FixedVersion: v.FixedVersion, Severity: v.Severity, Title: typeof v.Title === "string" ? v.Title.slice(0, 300) : undefined }
192
+ }),
193
+ })),
194
+ }
195
+ log(`ScaleQuality: scanned ${path}: ${packages} components, ${vulns} vulnerabilities`)
196
+ return JSON.stringify(out)
197
+ }
198
+
199
+ /** The license gate, in the log and in the job summary (GitHub Actions). */
200
+ function reportLicenses(license) {
201
+ if (!license || license.result === "PASSED" || !license.newForbidden?.length) return
202
+ const total = license.newForbiddenTotal ?? license.newForbidden.length
203
+ const blocked = license.result === "BLOCKED"
204
+ const head = blocked
205
+ ? `ScaleQuality: ${total} new dependenc${total === 1 ? "y has" : "ies have"} a license your company policy forbids, so the pull request is blocked.`
206
+ : `ScaleQuality: ${total} new dependenc${total === 1 ? "y has" : "ies have"} a license your company policy forbids (warning; blocking is off for your organization).`
207
+ const lines = license.newForbidden.map((p) => ` - ${p.name}${p.version ? ` ${p.version}` : ""}: ${(p.licenses || []).join(", ")}${p.file ? ` (${p.file})` : ""}`)
208
+ ;(blocked ? console.error : console.log)([head, ...lines, " Remove it, or ask an Owner or Admin to accept it with an exception in ScaleQuality."].join("\n"))
209
+ if (process.env.GITHUB_STEP_SUMMARY) {
210
+ const md = [`### ${blocked ? "Blocked" : "Warning"}: forbidden license`, "", "| Package | Version | License | Found in |", "|---|---|---|---|",
211
+ ...license.newForbidden.map((p) => `| ${p.name} | ${p.version || ""} | ${(p.licenses || []).join(", ")} | ${p.file || ""} |`),
212
+ "", "Remove the dependency, or ask an Owner or Admin to accept it with an exception in ScaleQuality.", ""].join("\n")
213
+ try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, md) } catch { /* the log already says it */ }
214
+ }
215
+ }
216
+
130
217
  async function main() {
131
218
  const auth = { Authorization: `Bearer ${token}` }
132
219
  const excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
@@ -160,6 +247,15 @@ async function main() {
160
247
  form.append("file", new Blob([buf]), "checkout.tar.gz")
161
248
  const containerReport = scanImage()
162
249
  if (containerReport) form.append("containerReport", containerReport)
250
+ const artifactReports = scanArtifacts()
251
+ if (artifactReports) form.append("artifactReports", artifactReports)
252
+ const ci = ciContext()
253
+ if (ci) {
254
+ form.append("ciProvider", ci.provider)
255
+ if (ci.baseRef) form.append("ciBaseRef", ci.baseRef)
256
+ if (ci.headRef) form.append("ciHeadRef", ci.headRef)
257
+ if (ci.pullRequest) form.append("ciPullRequest", String(ci.pullRequest))
258
+ }
163
259
  const r = await fetch(`${base}/report/upload`, {
164
260
  method: "POST",
165
261
  headers: auth,
@@ -213,7 +309,12 @@ async function main() {
213
309
  } else {
214
310
  const cut = last.cutoff != null ? ` (cutoff ${last.cutoff})` : " (track only)"
215
311
  log(`ScaleQuality: maturity ${last.maturity ?? "—"}${cut}`)
216
- if (last.passed === false) console.error("ScaleQuality: below the line — failing the build.")
312
+ reportLicenses(last.license)
313
+ const licenseBlocked = last.license?.result === "BLOCKED"
314
+ const maturityFailed = last.gateResult === "FAILED"
315
+ if (last.passed === false && maturityFailed) console.error("ScaleQuality: below the line — failing the build.")
316
+ else if (last.passed === false && licenseBlocked) console.error("ScaleQuality: a forbidden license, failing the build.")
317
+ else if (last.passed === false) console.error("ScaleQuality: the gate did not pass, failing the build.")
217
318
  else if (last.cutoff == null) log("ScaleQuality: measurement completed (track only; no cutoff configured).")
218
319
  else log("ScaleQuality: passed.")
219
320
  }
package/bin/ci.mjs ADDED
@@ -0,0 +1,18 @@
1
+ // Where a CI run happened, read from the provider's own variables.
2
+
3
+ /**
4
+ * Where this run happened, from the CI's own variables: the pull request's base
5
+ * (what the license gate compares against), its head and number. Outside a pull
6
+ * request only the branch is known, and the base stays empty: never guessed.
7
+ */
8
+ export function ciContext(env = process.env) {
9
+ const strip = (v) => (v ? String(v).replace(/^refs\/heads\//, "") : null)
10
+ if (env.GITHUB_ACTIONS) {
11
+ const pr = /^refs\/pull\/(\d+)\//.exec(env.GITHUB_REF || "")
12
+ return { provider: "github", baseRef: env.GITHUB_BASE_REF || null, headRef: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || null, pullRequest: pr ? pr[1] : null }
13
+ }
14
+ if (env.GITLAB_CI) return { provider: "gitlab", baseRef: env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || null, headRef: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || env.CI_COMMIT_REF_NAME || null, pullRequest: env.CI_MERGE_REQUEST_IID || null }
15
+ if (env.TF_BUILD) return { provider: "azure", baseRef: strip(env.SYSTEM_PULLREQUEST_TARGETBRANCH), headRef: strip(env.SYSTEM_PULLREQUEST_SOURCEBRANCH || env.BUILD_SOURCEBRANCH), pullRequest: env.SYSTEM_PULLREQUEST_PULLREQUESTID || null }
16
+ if (env.BITBUCKET_BUILD_NUMBER) return { provider: "bitbucket", baseRef: env.BITBUCKET_PR_DESTINATION_BRANCH || null, headRef: env.BITBUCKET_BRANCH || null, pullRequest: env.BITBUCKET_PR_ID || null }
17
+ return null
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalequality/check",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "ScaleQuality continuous code-maturity check for CI. Re-measures your repo on every build and fails the pipeline below your maturity line.",
5
5
  "type": "module",
6
6
  "bin": {