@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 +25 -1
- package/bin/check.mjs +104 -3
- package/bin/ci.mjs +18 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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