@cdxgen/cdxgen 13.6.0 → 13.7.0
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 +15 -8
- package/bin/audit.js +7 -4
- package/bin/cdxgen.js +256 -37
- package/bin/convert.js +146 -22
- package/bin/evinse.js +25 -3
- package/bin/repl.js +47 -3
- package/bin/sign.js +211 -34
- package/bin/tracebom.js +18 -6
- package/bin/validate.js +18 -3
- package/bin/verify.js +113 -5
- package/data/behavior-taxonomy.schema.json +1491 -0
- package/data/cyclonedx-2.0-bundled.schema.json +18606 -5528
- package/data/cyclonedx-2.0-cryptography-defs.schema.json +857 -0
- package/data/pqc-migration.json +239 -0
- package/data/remediations.json +52 -0
- package/data/rules/cbom-security.yaml +4 -3
- package/data/rules/package-integrity.yaml +28 -0
- package/lib/audit/index.js +25 -3
- package/lib/cli/bomAssembly.js +80 -1
- package/lib/cli/cliOptions.js +121 -17
- package/lib/cli/index.js +609 -898
- package/lib/cli/jsBom.js +3 -0
- package/lib/cli/jvmBom.js +365 -40
- package/lib/cli/managedBom.js +19 -3
- package/lib/cli/nativeBom.js +10 -0
- package/lib/core/env.js +26 -0
- package/lib/core/fs.js +126 -3
- package/lib/core/globPatterns.js +218 -0
- package/lib/core/processTree.js +99 -12
- package/lib/core/projectConfig.js +332 -25
- package/lib/core/toolRegistry.js +15 -1
- package/lib/ecosystems/denoutils.js +2 -2
- package/lib/ecosystems/golem.js +3 -8
- package/lib/ecosystems/gradleutils.js +1082 -6
- package/lib/ecosystems/jsEvidence.js +90 -38
- package/lib/ecosystems/kosi.js +3 -5
- package/lib/ecosystems/npmVersionResolution.js +376 -0
- package/lib/ecosystems/parsers-python.js +89 -28
- package/lib/ecosystems/parsers-rust.js +117 -15
- package/lib/ecosystems/remote/huggingface.js +22 -0
- package/lib/ecosystems/rusi.js +2 -4
- package/lib/ecosystems/scalasem.js +2 -1
- package/lib/evinser/evinser.js +196 -34
- package/lib/evinser/scalasem.js +5 -1
- package/lib/helpers/agenticToolutils.js +112 -1
- package/lib/helpers/bomSigner.js +43 -9
- package/lib/helpers/jssSigner.js +1276 -0
- package/lib/inventory/agentBlueprint.js +193 -0
- package/lib/inventory/agentToolNames.js +145 -0
- package/lib/inventory/aiCollector.js +153 -7
- package/lib/inventory/aiInventory.js +44 -8
- package/lib/inventory/aiProviders.js +324 -0
- package/lib/inventory/bomUtils.js +8 -1
- package/lib/inventory/ciParsers/githubActionsLock.js +821 -0
- package/lib/inventory/citations.js +31 -2
- package/lib/inventory/communityAiConfigParser.js +978 -80
- package/lib/inventory/cyclonedx20.js +152 -0
- package/lib/inventory/cyclonedx20Common.js +286 -0
- package/lib/inventory/cyclonedx20Derived.js +159 -0
- package/lib/inventory/cyclonedx20Parties.js +552 -0
- package/lib/inventory/cyclonedx20Read.js +768 -0
- package/lib/inventory/cyclonedx20ReadCommon.js +374 -0
- package/lib/inventory/cyclonedx20ReadComponents.js +692 -0
- package/lib/inventory/cyclonedx20ReadContext.js +380 -0
- package/lib/inventory/cyclonedx20ReadEvidence.js +466 -0
- package/lib/inventory/cyclonedx20ReadSections.js +599 -0
- package/lib/inventory/cyclonedx20Render.js +766 -0
- package/lib/inventory/cyclonedx20RenderComponents.js +716 -0
- package/lib/inventory/cyclonedx20RenderDefinitions.js +405 -0
- package/lib/inventory/cyclonedx20RenderEvidence.js +726 -0
- package/lib/inventory/cyclonedx20RenderHardware.js +203 -0
- package/lib/inventory/cyclonedx20RenderPatents.js +174 -0
- package/lib/inventory/cyclonedx20RenderRefIds.js +314 -0
- package/lib/inventory/cyclonedx20RenderSections.js +499 -0
- package/lib/inventory/cyclonedx20Tables.js +594 -0
- package/lib/inventory/deps.js +106 -13
- package/lib/inventory/dosai.js +236 -1
- package/lib/inventory/dosaiParsers.js +3 -6
- package/lib/inventory/dotnetRuntime.js +164 -14
- package/lib/inventory/ecosystemFormulationParsers.js +979 -0
- package/lib/inventory/envcontext.js +132 -38
- package/lib/inventory/evidenceUtils.js +2 -1
- package/lib/inventory/formulationParsers.js +95 -13
- package/lib/inventory/hbom.js +45 -6
- package/lib/inventory/hbomLoader.js +166 -0
- package/lib/inventory/mcpConfigParser.js +5 -0
- package/lib/inventory/occurrenceKey.js +90 -0
- package/lib/inventory/pqcMigration.js +426 -0
- package/lib/inventory/rustFormulationParser.js +21 -10
- package/lib/inventory/source.js +65 -6
- package/lib/parsers/huggingfaceManifest.js +9 -1
- package/lib/parsers/huggingfaceUtils.js +18 -0
- package/lib/parsers/kiroPermissions.js +166 -0
- package/lib/server/server.js +36 -34
- package/lib/stages/postgen/postgen.js +41 -1
- package/lib/stages/postgen/specVersionCompat.js +76 -0
- package/lib/validator/bomValidator.js +8 -1
- package/lib/validator/complianceEngine.js +30 -3
- package/lib/validator/complianceRules.js +1416 -4
- package/lib/validator/generated/validate-2.0.mjs +57554 -13851
- package/lib/validator/index.js +48 -7
- package/lib/validator/reporters/json.js +6 -0
- package/package.json +9 -9
- package/types/bin/repl.d.ts.map +1 -1
- package/types/lib/audit/index.d.ts +6 -2
- package/types/lib/audit/index.d.ts.map +1 -1
- package/types/lib/cli/bomAssembly.d.ts +16 -0
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +6 -0
- package/types/lib/cli/cliOptions.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts +2 -1
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts +15 -0
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/boundedFile.d.ts +30 -0
- package/types/lib/core/boundedFile.d.ts.map +1 -1
- package/types/lib/core/env.d.ts +10 -0
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts +4 -0
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/globPatterns.d.ts +70 -0
- package/types/lib/core/globPatterns.d.ts.map +1 -0
- package/types/lib/core/paths.d.ts +9 -0
- package/types/lib/core/paths.d.ts.map +1 -1
- package/types/lib/core/processTree.d.ts +16 -5
- package/types/lib/core/processTree.d.ts.map +1 -1
- package/types/lib/core/projectConfig.d.ts +47 -8
- package/types/lib/core/projectConfig.d.ts.map +1 -1
- package/types/lib/core/toolRegistry.d.ts.map +1 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +248 -8
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts +14 -1
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts.map +1 -1
- package/types/lib/ecosystems/npmVersionResolution.d.ts +119 -0
- package/types/lib/ecosystems/npmVersionResolution.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +1 -1
- package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/ecosystems/scalasem.d.ts +58 -16
- package/types/lib/ecosystems/scalasem.d.ts.map +1 -1
- package/types/lib/evinser/evinser.d.ts +61 -9
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/evinser/scalasem.d.ts.map +1 -1
- package/types/lib/helpers/agenticToolutils.d.ts +53 -19
- package/types/lib/helpers/agenticToolutils.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +20 -3
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/helpers/jssSigner.d.ts +288 -0
- package/types/lib/helpers/jssSigner.d.ts.map +1 -0
- package/types/lib/helpers/vsixutils.d.ts +303 -19
- package/types/lib/helpers/vsixutils.d.ts.map +1 -1
- package/types/lib/inventory/agentBlueprint.d.ts +70 -0
- package/types/lib/inventory/agentBlueprint.d.ts.map +1 -0
- package/types/lib/inventory/agentToolNames.d.ts +51 -0
- package/types/lib/inventory/agentToolNames.d.ts.map +1 -0
- package/types/lib/inventory/aiCollector.d.ts.map +1 -1
- package/types/lib/inventory/aiInventory.d.ts.map +1 -1
- package/types/lib/inventory/aiProviders.d.ts +85 -0
- package/types/lib/inventory/aiProviders.d.ts.map +1 -0
- package/types/lib/inventory/bomUtils.d.ts.map +1 -1
- package/types/lib/inventory/ciParsers/githubActionsLock.d.ts +101 -0
- package/types/lib/inventory/ciParsers/githubActionsLock.d.ts.map +1 -0
- package/types/lib/inventory/citations.d.ts +5 -0
- package/types/lib/inventory/citations.d.ts.map +1 -1
- package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/cyclonedx20.d.ts +99 -0
- package/types/lib/inventory/cyclonedx20.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20Common.d.ts +102 -0
- package/types/lib/inventory/cyclonedx20Common.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20Derived.d.ts +74 -0
- package/types/lib/inventory/cyclonedx20Derived.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20Parties.d.ts +91 -0
- package/types/lib/inventory/cyclonedx20Parties.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20Read.d.ts +51 -0
- package/types/lib/inventory/cyclonedx20Read.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20ReadCommon.d.ts +69 -0
- package/types/lib/inventory/cyclonedx20ReadCommon.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20ReadComponents.d.ts +77 -0
- package/types/lib/inventory/cyclonedx20ReadComponents.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20ReadContext.d.ts +118 -0
- package/types/lib/inventory/cyclonedx20ReadContext.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20ReadEvidence.d.ts +40 -0
- package/types/lib/inventory/cyclonedx20ReadEvidence.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20ReadSections.d.ts +45 -0
- package/types/lib/inventory/cyclonedx20ReadSections.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20Render.d.ts +53 -0
- package/types/lib/inventory/cyclonedx20Render.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20RenderComponents.d.ts +73 -0
- package/types/lib/inventory/cyclonedx20RenderComponents.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20RenderDefinitions.d.ts +29 -0
- package/types/lib/inventory/cyclonedx20RenderDefinitions.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20RenderEvidence.d.ts +110 -0
- package/types/lib/inventory/cyclonedx20RenderEvidence.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20RenderHardware.d.ts +42 -0
- package/types/lib/inventory/cyclonedx20RenderHardware.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20RenderPatents.d.ts +26 -0
- package/types/lib/inventory/cyclonedx20RenderPatents.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20RenderRefIds.d.ts +55 -0
- package/types/lib/inventory/cyclonedx20RenderRefIds.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20RenderSections.d.ts +45 -0
- package/types/lib/inventory/cyclonedx20RenderSections.d.ts.map +1 -0
- package/types/lib/inventory/cyclonedx20Tables.d.ts +134 -0
- package/types/lib/inventory/cyclonedx20Tables.d.ts.map +1 -0
- package/types/lib/inventory/deps.d.ts +35 -2
- package/types/lib/inventory/deps.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +19 -0
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
- package/types/lib/inventory/dotnetRuntime.d.ts +51 -4
- package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -1
- package/types/lib/inventory/ecosystemFormulationParsers.d.ts +73 -0
- package/types/lib/inventory/ecosystemFormulationParsers.d.ts.map +1 -0
- package/types/lib/inventory/envcontext.d.ts +22 -6
- package/types/lib/inventory/envcontext.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
- package/types/lib/inventory/hbom.d.ts +11 -0
- package/types/lib/inventory/hbom.d.ts.map +1 -1
- package/types/lib/inventory/hbomLoader.d.ts +54 -0
- package/types/lib/inventory/hbomLoader.d.ts.map +1 -1
- package/types/lib/inventory/jvmLocalRepos.d.ts +5 -1
- package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -1
- package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/occurrenceKey.d.ts +52 -0
- package/types/lib/inventory/occurrenceKey.d.ts.map +1 -0
- package/types/lib/inventory/pqcMigration.d.ts +84 -0
- package/types/lib/inventory/pqcMigration.d.ts.map +1 -0
- package/types/lib/inventory/rustFormulationParser.d.ts +7 -2
- package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
- package/types/lib/inventory/scalaCoords.d.ts.map +1 -1
- package/types/lib/inventory/scalaDedupe.d.ts +43 -0
- package/types/lib/inventory/scalaDedupe.d.ts.map +1 -0
- package/types/lib/inventory/source.d.ts +15 -0
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/parsers/huggingfaceManifest.d.ts +10 -1
- package/types/lib/parsers/huggingfaceManifest.d.ts.map +1 -1
- package/types/lib/parsers/huggingfaceUtils.d.ts +12 -0
- package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
- package/types/lib/parsers/kiroPermissions.d.ts +62 -0
- package/types/lib/parsers/kiroPermissions.d.ts.map +1 -0
- package/types/lib/parsers/vscodeExtensionId.d.ts +48 -0
- package/types/lib/parsers/vscodeExtensionId.d.ts.map +1 -0
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts +7 -0
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
- package/types/lib/stages/postgen/specVersionCompat.d.ts +25 -0
- package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts +5 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/complianceEngine.d.ts +4 -4
- package/types/lib/validator/complianceEngine.d.ts.map +1 -1
- package/types/lib/validator/complianceRules.d.ts +14 -2
- package/types/lib/validator/complianceRules.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +10 -0
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
// Reader for the GitHub Actions workflow dependency lockfile written by
|
|
2
|
+
// `gh actions-lock` (`.github/workflows/actions.lock`).
|
|
3
|
+
//
|
|
4
|
+
// The lockfile is a small YAML document describing the pinned action
|
|
5
|
+
// dependency graph of a repository's workflows. Three schema versions are
|
|
6
|
+
// published: v0.0.1 carries `OWNER/REPO@REF:ALGO-HEX` pin keys and resolves
|
|
7
|
+
// refs through `tag`/`branch` entry fields; v0.0.2 moved the ref into the
|
|
8
|
+
// entry and shortened the pin keys to `OWNER/REPO@REF`; v0.0.3 added an
|
|
9
|
+
// optional per-entry `hostname` for pins bound to github.com or a GHE.com
|
|
10
|
+
// data-residency tenant. Older files are normalised to the v0.0.2 shape so
|
|
11
|
+
// callers see one key format.
|
|
12
|
+
//
|
|
13
|
+
// This module only reads and validates the lockfile. Joining the entries to
|
|
14
|
+
// the components found by the workflow parser happens elsewhere, so the
|
|
15
|
+
// return value stays free of CycloneDX concepts. The reader never throws:
|
|
16
|
+
// every problem is reported as a warning and collapses the result to the
|
|
17
|
+
// empty shape.
|
|
18
|
+
|
|
19
|
+
import { statSync } from "node:fs";
|
|
20
|
+
|
|
21
|
+
import { parse as parseYaml } from "yaml";
|
|
22
|
+
|
|
23
|
+
import { DEBUG_MODE } from "../../core/activity.js";
|
|
24
|
+
import { safeExistsSync, safeReadFileSync } from "../../core/fs.js";
|
|
25
|
+
import { thoughtLog } from "../../core/logger.js";
|
|
26
|
+
import { scanTextForHiddenUnicode } from "../unicodeScan.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Maximum accepted lockfile size. `gh actions-lock` writes files that stay
|
|
30
|
+
* far below this; anything larger is treated as corrupt rather than parsed.
|
|
31
|
+
* @type {number}
|
|
32
|
+
*/
|
|
33
|
+
export const MAX_ACTIONS_LOCKFILE_BYTES = 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
const SUPPORTED_VERSIONS = new Set(["v0.0.1", "v0.0.2", "v0.0.3"]);
|
|
36
|
+
|
|
37
|
+
const TOP_LEVEL_FIELDS = new Set(["version", "workflows", "dependencies"]);
|
|
38
|
+
|
|
39
|
+
const ENTRY_FIELDS_V0_0_1 = new Set([
|
|
40
|
+
"tag",
|
|
41
|
+
"branch",
|
|
42
|
+
"commit",
|
|
43
|
+
"owner_id",
|
|
44
|
+
"repo_id",
|
|
45
|
+
"uses",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const ENTRY_FIELDS_V0_0_2 = new Set([
|
|
49
|
+
"ref",
|
|
50
|
+
"commit",
|
|
51
|
+
"owner_id",
|
|
52
|
+
"repo_id",
|
|
53
|
+
"uses",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const ENTRY_FIELDS_V0_0_3 = new Set([...ENTRY_FIELDS_V0_0_2, "hostname"]);
|
|
57
|
+
|
|
58
|
+
const SHA1_HEX_LENGTH = 40;
|
|
59
|
+
const SHA256_HEX_LENGTH = 64;
|
|
60
|
+
const HEX_DIGEST_PATTERN = /^[a-f0-9]+$/;
|
|
61
|
+
const GHE_HOSTNAME_SUFFIX = ".ghe.com";
|
|
62
|
+
// One label of a GHE.com tenant host: lowercase letters, digits and inner
|
|
63
|
+
// hyphens, 1 to 63 characters long.
|
|
64
|
+
const GHE_HOSTNAME_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
65
|
+
const DRIVE_ROOT_PATTERN = /^[A-Za-z]:/;
|
|
66
|
+
const YAML_ERROR_LINE_PATTERN = /at line (\d+)/;
|
|
67
|
+
const ALIAS_DISABLED_MARKER = "Alias resolution is disabled";
|
|
68
|
+
const MAX_WARNING_DETAIL_LENGTH = 200;
|
|
69
|
+
const MAX_CYCLE_NODES_IN_WARNING = 5;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One resolved action entry of the lockfile, normalised to the v0.0.2 shape.
|
|
73
|
+
*
|
|
74
|
+
* @typedef {Object} ActionsLockDependency
|
|
75
|
+
* @property {string} owner Owner name, lower case.
|
|
76
|
+
* @property {string} repo Repository name, lower case.
|
|
77
|
+
* @property {string} ref Ref the commit was resolved from, with its case
|
|
78
|
+
* preserved. For a v0.0.1 entry this is the tag, else the branch. It can
|
|
79
|
+
* differ from the ref in the pin key: the key keeps the ref the workflow
|
|
80
|
+
* wrote (for example `v4`) while this field records the resolved one (for
|
|
81
|
+
* example `v4.3.1`).
|
|
82
|
+
* @property {string} hashAlgo "sha1" or "sha256".
|
|
83
|
+
* @property {string} commit Hex digest without the algorithm prefix.
|
|
84
|
+
* @property {number} ownerId Numeric id of the action's owner.
|
|
85
|
+
* @property {number} repoId Numeric id of the action's repository.
|
|
86
|
+
* @property {string|undefined} hostname Set only when the entry records one
|
|
87
|
+
* (v0.0.3); undefined means the home host.
|
|
88
|
+
* @property {string[]} uses Normalised pin keys of the direct dependencies;
|
|
89
|
+
* empty for leaf actions.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parsed lockfile, or the empty shape when the file is unreadable or invalid.
|
|
94
|
+
*
|
|
95
|
+
* @typedef {Object} ActionsLockfile
|
|
96
|
+
* @property {string|null} version Version as written in the file; null for an
|
|
97
|
+
* empty result.
|
|
98
|
+
* @property {string|undefined} sourceVersion Set only when an older version
|
|
99
|
+
* was normalised.
|
|
100
|
+
* @property {Object<string, string[]>} workflows Map of repository-relative
|
|
101
|
+
* workflow path (always with "/" separators) to its flat pin list.
|
|
102
|
+
* @property {Object<string, ActionsLockDependency>} dependencies Map of
|
|
103
|
+
* normalised pin key to the resolved entry.
|
|
104
|
+
* @property {string[]} warnings Human-readable problems found while reading.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Raised internally when the lockfile content is invalid. Never escapes the
|
|
109
|
+
* module; the caller converts it into the empty result plus warnings.
|
|
110
|
+
*/
|
|
111
|
+
class ActionsLockParseAbort extends Error {
|
|
112
|
+
constructor(message) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = "ActionsLockParseAbort";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function recordWarning(context, message) {
|
|
119
|
+
const label = context.filePath || "actions lockfile";
|
|
120
|
+
const text = `${label}: ${message}`;
|
|
121
|
+
context.warnings.push(text);
|
|
122
|
+
thoughtLog(`actions lockfile warning: ${text}`);
|
|
123
|
+
if (DEBUG_MODE) {
|
|
124
|
+
console.warn(`cdxgen: actions lockfile issue: ${text}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function fail(context, message) {
|
|
129
|
+
recordWarning(context, message);
|
|
130
|
+
throw new ActionsLockParseAbort(message);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function truncateForWarning(text) {
|
|
134
|
+
const singleLine = String(text).split("\n")[0];
|
|
135
|
+
if (singleLine.length <= MAX_WARNING_DETAIL_LENGTH) {
|
|
136
|
+
return singleLine;
|
|
137
|
+
}
|
|
138
|
+
return `${singleLine.slice(0, MAX_WARNING_DETAIL_LENGTH)}...`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function emptyLockResult(warnings) {
|
|
142
|
+
return {
|
|
143
|
+
version: null,
|
|
144
|
+
sourceVersion: undefined,
|
|
145
|
+
workflows: {},
|
|
146
|
+
dependencies: {},
|
|
147
|
+
warnings,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isLowerHexOfLength(text, length) {
|
|
152
|
+
return text.length === length && HEX_DIGEST_PATTERN.test(text);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validate an algo-prefixed commit digest such as `sha1-<40 hex>`.
|
|
157
|
+
*
|
|
158
|
+
* @param {unknown} commit Candidate value
|
|
159
|
+
* @returns {boolean} Whether the value is a well-formed digest
|
|
160
|
+
*/
|
|
161
|
+
function isValidCommitDigest(commit) {
|
|
162
|
+
if (typeof commit !== "string") {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
if (commit.startsWith("sha1-")) {
|
|
166
|
+
return isLowerHexOfLength(commit.slice(5), SHA1_HEX_LENGTH);
|
|
167
|
+
}
|
|
168
|
+
if (commit.startsWith("sha256-")) {
|
|
169
|
+
return isLowerHexOfLength(commit.slice(7), SHA256_HEX_LENGTH);
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Validate a v0.0.3 `hostname`: `github.com` or a lowercase
|
|
176
|
+
* `<tenant>.ghe.com` name.
|
|
177
|
+
*
|
|
178
|
+
* @param {unknown} hostname Candidate value
|
|
179
|
+
* @returns {boolean} Whether the value is an accepted host name
|
|
180
|
+
*/
|
|
181
|
+
function isValidHostname(hostname) {
|
|
182
|
+
if (hostname === "github.com") {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (typeof hostname !== "string" || !hostname.endsWith(GHE_HOSTNAME_SUFFIX)) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const label = hostname.slice(0, -GHE_HOSTNAME_SUFFIX.length);
|
|
189
|
+
return GHE_HOSTNAME_LABEL_PATTERN.test(label);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Split a pin key into owner, repo and ref. Only the shape is checked here;
|
|
194
|
+
* the v0.0.1 digest suffix is handled by the caller.
|
|
195
|
+
*
|
|
196
|
+
* @param {unknown} pinKey Candidate pin key
|
|
197
|
+
* @param {boolean} isV0_0_1 Whether the file uses v0.0.1 keys
|
|
198
|
+
* @returns {{ owner: string, repo: string, ref: string }|undefined} Parts, or
|
|
199
|
+
* undefined when the key is not a valid pin
|
|
200
|
+
*/
|
|
201
|
+
function splitPinKey(pinKey, isV0_0_1) {
|
|
202
|
+
if (typeof pinKey !== "string") {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const atIndex = pinKey.indexOf("@");
|
|
206
|
+
if (atIndex <= 0) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
const ownerRepo = pinKey.slice(0, atIndex);
|
|
210
|
+
const rest = pinKey.slice(atIndex + 1);
|
|
211
|
+
if (!rest.length) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
const slashIndex = ownerRepo.indexOf("/");
|
|
215
|
+
if (slashIndex <= 0) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
// A second slash is a sub-action path such as actions/cache/save@v4.
|
|
219
|
+
if (ownerRepo.indexOf("/", slashIndex + 1) !== -1) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
if (ownerRepo.includes(":")) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
// A second "@" means the ref itself contains "@".
|
|
226
|
+
if (rest.includes("@")) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
// Only v0.0.1 keys carry the `:ALGO-HEX` suffix; everywhere else a colon
|
|
230
|
+
// cannot appear in the ref.
|
|
231
|
+
if (!isV0_0_1 && rest.includes(":")) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const owner = ownerRepo.slice(0, slashIndex).toLowerCase();
|
|
235
|
+
const repo = ownerRepo.slice(slashIndex + 1).toLowerCase();
|
|
236
|
+
if (!owner.length || !repo.length) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
return { owner, repo, ref: rest };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Split the ref part of a v0.0.1 pin into the ref and the digest suffix.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} refWithSuffix Text after the "@" of a v0.0.1 pin key
|
|
246
|
+
* @returns {{ ref: string, digest: string }|undefined} Parts, or undefined
|
|
247
|
+
* when the digest suffix is missing
|
|
248
|
+
*/
|
|
249
|
+
function splitV0_0_1Ref(refWithSuffix) {
|
|
250
|
+
const colonIndex = refWithSuffix.indexOf(":");
|
|
251
|
+
if (colonIndex <= 0) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
const ref = refWithSuffix.slice(0, colonIndex);
|
|
255
|
+
const digest = refWithSuffix.slice(colonIndex + 1);
|
|
256
|
+
if (!ref.length) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
return { ref, digest };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Parse a pin key as written in the file into its normalised v0.0.2 form,
|
|
264
|
+
* failing with a warning when the key is malformed.
|
|
265
|
+
*
|
|
266
|
+
* @param {unknown} rawPin Pin key as written in the file
|
|
267
|
+
* @param {boolean} isV0_0_1 Whether the file uses v0.0.1 keys
|
|
268
|
+
* @param {Object} context Warning context
|
|
269
|
+
* @param {string} where Where the key was found, for the warning
|
|
270
|
+
* @returns {{ owner: string, repo: string, key: string, keyDigest: string|undefined }}
|
|
271
|
+
* Lower-cased owner and repository, the normalised key, and the v0.0.1
|
|
272
|
+
* digest suffix when there is one
|
|
273
|
+
*/
|
|
274
|
+
function parsePinKey(rawPin, isV0_0_1, context, where) {
|
|
275
|
+
const pin = splitPinKey(rawPin, isV0_0_1);
|
|
276
|
+
if (!pin) {
|
|
277
|
+
fail(
|
|
278
|
+
context,
|
|
279
|
+
`invalid pin key '${truncateForWarning(rawPin)}' in ${where}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
let ref = pin.ref;
|
|
283
|
+
let keyDigest;
|
|
284
|
+
if (isV0_0_1) {
|
|
285
|
+
const split = splitV0_0_1Ref(pin.ref);
|
|
286
|
+
if (!split) {
|
|
287
|
+
fail(
|
|
288
|
+
context,
|
|
289
|
+
`pin key '${truncateForWarning(rawPin)}' in ${where} lacks a sha1- or sha256- digest suffix`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
ref = split.ref;
|
|
293
|
+
keyDigest = split.digest;
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
owner: pin.owner,
|
|
297
|
+
repo: pin.repo,
|
|
298
|
+
key: `${pin.owner}/${pin.repo}@${ref}`,
|
|
299
|
+
keyDigest,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function fullCommitOf(entry) {
|
|
304
|
+
return `${entry.hashAlgo}-${entry.commit}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Resolve one pin reference found in a workflow list or a uses list: parse
|
|
309
|
+
* the key, normalise it to the v0.0.2 shape, and require that the referenced
|
|
310
|
+
* entry exists with a matching v0.0.1 digest.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} rawPin Pin key as written in the file
|
|
313
|
+
* @param {Map<string, Object>} entries Normalised dependency entries
|
|
314
|
+
* @param {boolean} isV0_0_1 Whether the file uses v0.0.1 keys
|
|
315
|
+
* @param {Object} context Warning context
|
|
316
|
+
* @param {string} where Where the reference was found, for the warning
|
|
317
|
+
* @returns {string} Normalised pin key
|
|
318
|
+
*/
|
|
319
|
+
function normalisePinReference(rawPin, entries, isV0_0_1, context, where) {
|
|
320
|
+
const { key: normalisedKey, keyDigest } = parsePinKey(
|
|
321
|
+
rawPin,
|
|
322
|
+
isV0_0_1,
|
|
323
|
+
context,
|
|
324
|
+
where,
|
|
325
|
+
);
|
|
326
|
+
const entry = entries.get(normalisedKey);
|
|
327
|
+
if (!entry) {
|
|
328
|
+
fail(
|
|
329
|
+
context,
|
|
330
|
+
`pin '${normalisedKey}' referenced in ${where} is not defined in dependencies`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (keyDigest && keyDigest !== fullCommitOf(entry)) {
|
|
334
|
+
fail(
|
|
335
|
+
context,
|
|
336
|
+
`pin key '${normalisedKey}' in ${where} carries digest '${keyDigest}' but the entry commits to '${fullCommitOf(entry)}'`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return normalisedKey;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Validate the `dependencies` mapping and normalise every entry to the
|
|
344
|
+
* v0.0.2 shape. The uses lists are kept raw here because they may reference
|
|
345
|
+
* entries that have not been seen yet.
|
|
346
|
+
*
|
|
347
|
+
* @param {Object} doc Parsed lockfile document
|
|
348
|
+
* @param {string} version Lockfile version as written
|
|
349
|
+
* @param {Object} context Warning context
|
|
350
|
+
* @returns {Map<string, Object>} Normalised entries by pin key
|
|
351
|
+
*/
|
|
352
|
+
function parseDependencyEntries(doc, version, context) {
|
|
353
|
+
const entries = new Map();
|
|
354
|
+
const raw = doc.dependencies;
|
|
355
|
+
if (raw === undefined) {
|
|
356
|
+
return entries;
|
|
357
|
+
}
|
|
358
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
359
|
+
fail(context, "dependencies must be a mapping of pin keys");
|
|
360
|
+
}
|
|
361
|
+
const isV0_0_1 = version === "v0.0.1";
|
|
362
|
+
const allowedFields = isV0_0_1
|
|
363
|
+
? ENTRY_FIELDS_V0_0_1
|
|
364
|
+
: version === "v0.0.2"
|
|
365
|
+
? ENTRY_FIELDS_V0_0_2
|
|
366
|
+
: ENTRY_FIELDS_V0_0_3;
|
|
367
|
+
for (const rawKey of Object.keys(raw)) {
|
|
368
|
+
const pin = parsePinKey(rawKey, isV0_0_1, context, "dependencies");
|
|
369
|
+
const normalisedKey = pin.key;
|
|
370
|
+
const keyDigest = pin.keyDigest;
|
|
371
|
+
if (entries.has(normalisedKey)) {
|
|
372
|
+
fail(
|
|
373
|
+
context,
|
|
374
|
+
isV0_0_1
|
|
375
|
+
? `dependencies contains two entries that normalise to the same pin key '${normalisedKey}' (the keys differ only in owner or repository case, or in the digest suffix)`
|
|
376
|
+
: `dependencies contains two entries that normalise to the same pin key '${normalisedKey}' (the keys differ only in owner or repository case)`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const entry = raw[rawKey];
|
|
380
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
381
|
+
fail(context, `entry '${normalisedKey}' must be a mapping`);
|
|
382
|
+
}
|
|
383
|
+
for (const fieldName of Object.keys(entry)) {
|
|
384
|
+
if (!allowedFields.has(fieldName)) {
|
|
385
|
+
fail(
|
|
386
|
+
context,
|
|
387
|
+
`unknown field '${fieldName}' in entry '${normalisedKey}'`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const commit = entry.commit;
|
|
392
|
+
if (commit === undefined) {
|
|
393
|
+
fail(context, `entry '${normalisedKey}' is missing the commit field`);
|
|
394
|
+
}
|
|
395
|
+
if (!isValidCommitDigest(commit)) {
|
|
396
|
+
fail(
|
|
397
|
+
context,
|
|
398
|
+
`entry '${normalisedKey}' has a malformed commit digest (expected sha1- with ${SHA1_HEX_LENGTH} or sha256- with ${SHA256_HEX_LENGTH} lowercase hex characters)`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (keyDigest && keyDigest !== commit) {
|
|
402
|
+
fail(
|
|
403
|
+
context,
|
|
404
|
+
`pin key '${normalisedKey}' carries digest '${keyDigest}' but the entry commits to '${commit}'`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const ownerId = entry.owner_id;
|
|
408
|
+
const repoId = entry.repo_id;
|
|
409
|
+
if (!Number.isInteger(ownerId) || ownerId < 1) {
|
|
410
|
+
fail(
|
|
411
|
+
context,
|
|
412
|
+
`entry '${normalisedKey}' needs a positive integer owner_id`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (!Number.isInteger(repoId) || repoId < 1) {
|
|
416
|
+
fail(
|
|
417
|
+
context,
|
|
418
|
+
`entry '${normalisedKey}' needs a positive integer repo_id`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
let entryRef;
|
|
422
|
+
if (isV0_0_1) {
|
|
423
|
+
const tag = entry.tag;
|
|
424
|
+
const branch = entry.branch;
|
|
425
|
+
if (tag !== undefined && (typeof tag !== "string" || !tag.length)) {
|
|
426
|
+
fail(context, `entry '${normalisedKey}' has a non-string tag`);
|
|
427
|
+
}
|
|
428
|
+
if (
|
|
429
|
+
branch !== undefined &&
|
|
430
|
+
(typeof branch !== "string" || !branch.length)
|
|
431
|
+
) {
|
|
432
|
+
fail(context, `entry '${normalisedKey}' has a non-string branch`);
|
|
433
|
+
}
|
|
434
|
+
if (tag === undefined && branch === undefined) {
|
|
435
|
+
fail(
|
|
436
|
+
context,
|
|
437
|
+
`entry '${normalisedKey}' needs a tag or a branch to resolve its ref`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
entryRef = tag !== undefined ? tag : branch;
|
|
441
|
+
} else {
|
|
442
|
+
const refField = entry.ref;
|
|
443
|
+
if (refField === undefined) {
|
|
444
|
+
fail(context, `entry '${normalisedKey}' is missing the ref field`);
|
|
445
|
+
}
|
|
446
|
+
if (typeof refField !== "string" || !refField.length) {
|
|
447
|
+
fail(context, `entry '${normalisedKey}' needs a non-empty ref string`);
|
|
448
|
+
}
|
|
449
|
+
entryRef = refField;
|
|
450
|
+
}
|
|
451
|
+
let hostname;
|
|
452
|
+
if (version === "v0.0.3" && entry.hostname !== undefined) {
|
|
453
|
+
if (!isValidHostname(entry.hostname)) {
|
|
454
|
+
fail(
|
|
455
|
+
context,
|
|
456
|
+
`entry '${normalisedKey}' has an invalid hostname '${truncateForWarning(entry.hostname)}' (expected github.com or a lowercase <tenant>.ghe.com name)`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
hostname = entry.hostname;
|
|
460
|
+
}
|
|
461
|
+
const rawUses = entry.uses;
|
|
462
|
+
if (rawUses !== undefined && !Array.isArray(rawUses)) {
|
|
463
|
+
fail(
|
|
464
|
+
context,
|
|
465
|
+
`entry '${normalisedKey}' has a uses list that is not an array`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
entries.set(normalisedKey, {
|
|
469
|
+
owner: pin.owner,
|
|
470
|
+
repo: pin.repo,
|
|
471
|
+
ref: entryRef,
|
|
472
|
+
hashAlgo: commit.startsWith("sha256-") ? "sha256" : "sha1",
|
|
473
|
+
commit: commit.slice(commit.indexOf("-") + 1),
|
|
474
|
+
ownerId,
|
|
475
|
+
repoId,
|
|
476
|
+
hostname,
|
|
477
|
+
rawUses: rawUses || [],
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return entries;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Resolve the uses lists of every entry into normalised pin keys.
|
|
485
|
+
*
|
|
486
|
+
* @param {Map<string, Object>} entries Normalised dependency entries
|
|
487
|
+
* @param {boolean} isV0_0_1 Whether the file uses v0.0.1 keys
|
|
488
|
+
* @param {Object} context Warning context
|
|
489
|
+
* @returns {Map<string, string[]>} Normalised direct edges by pin key
|
|
490
|
+
*/
|
|
491
|
+
function resolveUsesEdges(entries, isV0_0_1, context) {
|
|
492
|
+
const usesMap = new Map();
|
|
493
|
+
for (const [key, entry] of entries) {
|
|
494
|
+
const edges = entry.rawUses.map((rawPin) =>
|
|
495
|
+
normalisePinReference(
|
|
496
|
+
rawPin,
|
|
497
|
+
entries,
|
|
498
|
+
isV0_0_1,
|
|
499
|
+
context,
|
|
500
|
+
`the uses list of '${key}'`,
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
usesMap.set(key, edges);
|
|
504
|
+
}
|
|
505
|
+
return usesMap;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Find a cycle in the uses graph with an iterative depth-first walk, so a
|
|
510
|
+
* deep graph cannot overflow the call stack. Returns the cycle as a list of
|
|
511
|
+
* pin keys ending where it started, or undefined when the graph is acyclic.
|
|
512
|
+
*
|
|
513
|
+
* @param {Map<string, string[]>} usesMap Normalised direct edges by pin key
|
|
514
|
+
* @returns {string[]|undefined} The cycle, or undefined
|
|
515
|
+
*/
|
|
516
|
+
function findUsesCycle(usesMap) {
|
|
517
|
+
const WHITE = 0;
|
|
518
|
+
const GRAY = 1;
|
|
519
|
+
const BLACK = 2;
|
|
520
|
+
const color = new Map();
|
|
521
|
+
for (const key of usesMap.keys()) {
|
|
522
|
+
color.set(key, WHITE);
|
|
523
|
+
}
|
|
524
|
+
for (const start of usesMap.keys()) {
|
|
525
|
+
if (color.get(start) !== WHITE) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
color.set(start, GRAY);
|
|
529
|
+
const stack = [[start, 0]];
|
|
530
|
+
while (stack.length) {
|
|
531
|
+
const frame = stack[stack.length - 1];
|
|
532
|
+
const edges = usesMap.get(frame[0]);
|
|
533
|
+
if (frame[1] >= edges.length) {
|
|
534
|
+
color.set(frame[0], BLACK);
|
|
535
|
+
stack.pop();
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const next = edges[frame[1]];
|
|
539
|
+
frame[1] += 1;
|
|
540
|
+
const nextColor = color.get(next);
|
|
541
|
+
if (nextColor === GRAY) {
|
|
542
|
+
const path = stack.map((frameEntry) => frameEntry[0]);
|
|
543
|
+
return path.slice(path.indexOf(next)).concat(next);
|
|
544
|
+
}
|
|
545
|
+
if (nextColor === WHITE) {
|
|
546
|
+
color.set(next, GRAY);
|
|
547
|
+
stack.push([next, 0]);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Validate one workflow path key and return the form stored in the result.
|
|
556
|
+
* Backslash separators are normalised so Windows-built keys round-trip with
|
|
557
|
+
* "/" on every platform; absolute paths, "." and ".." segments are rejected,
|
|
558
|
+
* so every stored key is in the canonical repository-relative form. The
|
|
559
|
+
* comparison stays case-sensitive, like git paths.
|
|
560
|
+
*
|
|
561
|
+
* @param {string} rawPath Workflow path as written in the file
|
|
562
|
+
* @param {Object} context Warning context
|
|
563
|
+
* @returns {string} Repository-relative path with "/" separators
|
|
564
|
+
*/
|
|
565
|
+
function normaliseWorkflowPath(rawPath, context) {
|
|
566
|
+
if (typeof rawPath !== "string" || !rawPath.length) {
|
|
567
|
+
fail(context, "workflows contains an empty path key");
|
|
568
|
+
}
|
|
569
|
+
const normalised = rawPath.split("\\").join("/");
|
|
570
|
+
if (!normalised.length) {
|
|
571
|
+
fail(context, "workflows contains an empty path key");
|
|
572
|
+
}
|
|
573
|
+
if (normalised.startsWith("/") || DRIVE_ROOT_PATTERN.test(normalised)) {
|
|
574
|
+
fail(context, `workflow path '${truncateForWarning(rawPath)}' is absolute`);
|
|
575
|
+
}
|
|
576
|
+
const segments = normalised.split("/");
|
|
577
|
+
if (segments.some((segment) => !segment.length)) {
|
|
578
|
+
fail(
|
|
579
|
+
context,
|
|
580
|
+
`workflow path '${truncateForWarning(rawPath)}' has an empty path segment`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (segments.includes("..")) {
|
|
584
|
+
fail(
|
|
585
|
+
context,
|
|
586
|
+
`workflow path '${truncateForWarning(rawPath)}' escapes the repository with a '..' segment`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
if (segments.includes(".")) {
|
|
590
|
+
fail(
|
|
591
|
+
context,
|
|
592
|
+
`workflow path '${truncateForWarning(rawPath)}' has a '.' segment; keys must be plain repository-relative paths`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return normalised;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Validate the `workflows` mapping and resolve its pin lists to normalised
|
|
600
|
+
* keys that must exist in dependencies.
|
|
601
|
+
*
|
|
602
|
+
* @param {Object} doc Parsed lockfile document
|
|
603
|
+
* @param {Map<string, Object>} entries Normalised dependency entries
|
|
604
|
+
* @param {boolean} isV0_0_1 Whether the file uses v0.0.1 keys
|
|
605
|
+
* @param {Object} context Warning context
|
|
606
|
+
* @returns {Object<string, string[]>} Workflow paths mapped to pin key lists
|
|
607
|
+
*/
|
|
608
|
+
function resolveWorkflows(doc, entries, isV0_0_1, context) {
|
|
609
|
+
const workflows = {};
|
|
610
|
+
const raw = doc.workflows;
|
|
611
|
+
if (raw === undefined) {
|
|
612
|
+
return workflows;
|
|
613
|
+
}
|
|
614
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
615
|
+
fail(context, "workflows must be a mapping of workflow paths to pin lists");
|
|
616
|
+
}
|
|
617
|
+
for (const rawPath of Object.keys(raw)) {
|
|
618
|
+
const storedPath = normaliseWorkflowPath(rawPath, context);
|
|
619
|
+
if (Object.hasOwn(workflows, storedPath)) {
|
|
620
|
+
fail(
|
|
621
|
+
context,
|
|
622
|
+
`workflows contains two keys that normalise to the same path '${storedPath}'`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
const pinList = raw[rawPath];
|
|
626
|
+
if (!Array.isArray(pinList)) {
|
|
627
|
+
fail(
|
|
628
|
+
context,
|
|
629
|
+
`workflow '${storedPath}' has a pin list that is not an array`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
const normalisedList = pinList.map((rawPin, index) => {
|
|
633
|
+
if (typeof rawPin !== "string") {
|
|
634
|
+
fail(
|
|
635
|
+
context,
|
|
636
|
+
`workflow '${storedPath}' has a non-string pin at position ${index}`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return normalisePinReference(
|
|
640
|
+
rawPin,
|
|
641
|
+
entries,
|
|
642
|
+
isV0_0_1,
|
|
643
|
+
context,
|
|
644
|
+
`the pin list of '${storedPath}'`,
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
// Workflow paths come from the lockfile, so a path could collide with a
|
|
648
|
+
// JS object name such as __proto__; defineProperty keeps it a plain key.
|
|
649
|
+
Object.defineProperty(workflows, storedPath, {
|
|
650
|
+
value: normalisedList,
|
|
651
|
+
enumerable: true,
|
|
652
|
+
writable: true,
|
|
653
|
+
configurable: true,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return workflows;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function failYaml(context, error) {
|
|
660
|
+
const message = typeof error?.message === "string" ? error.message : "";
|
|
661
|
+
if (error?.code === "DUPLICATE_KEY") {
|
|
662
|
+
const lineMatch = YAML_ERROR_LINE_PATTERN.exec(message);
|
|
663
|
+
fail(
|
|
664
|
+
context,
|
|
665
|
+
`duplicate mapping key${lineMatch ? ` at line ${lineMatch[1]}` : ""}`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (message.includes(ALIAS_DISABLED_MARKER)) {
|
|
669
|
+
fail(context, "YAML aliases are not allowed in the lockfile");
|
|
670
|
+
}
|
|
671
|
+
fail(context, `invalid YAML (${truncateForWarning(message)})`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Parse an actions lockfile from its text content.
|
|
676
|
+
*
|
|
677
|
+
* Every problem, including a file that cannot be parsed at all, is reported
|
|
678
|
+
* as a warning and collapses the result to the empty shape; the function
|
|
679
|
+
* never throws.
|
|
680
|
+
*
|
|
681
|
+
* @param {string} text Lockfile content
|
|
682
|
+
* @param {string} [filePath] Path the content came from, used in warnings
|
|
683
|
+
* @returns {ActionsLockfile} Parsed lockfile, or the empty shape
|
|
684
|
+
*/
|
|
685
|
+
export function parseActionsLockfileString(text, filePath) {
|
|
686
|
+
const warnings = [];
|
|
687
|
+
const context = { filePath, warnings };
|
|
688
|
+
try {
|
|
689
|
+
if (typeof text !== "string") {
|
|
690
|
+
fail(context, "the lockfile content is not text");
|
|
691
|
+
}
|
|
692
|
+
const unicodeScan = scanTextForHiddenUnicode(text, { syntax: "yaml" });
|
|
693
|
+
if (unicodeScan.hasHiddenUnicode) {
|
|
694
|
+
recordWarning(
|
|
695
|
+
context,
|
|
696
|
+
`hidden Unicode characters found at line(s) ${unicodeScan.lineNumbers.join(", ")} (${unicodeScan.codePoints.join(", ")})`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
let doc;
|
|
700
|
+
try {
|
|
701
|
+
// Aliases are banned outright: a document is a few hundred flat lines,
|
|
702
|
+
// so there is nothing an alias could legitimately expand to.
|
|
703
|
+
doc = parseYaml(text, { maxAliasCount: 0 });
|
|
704
|
+
} catch (error) {
|
|
705
|
+
failYaml(context, error);
|
|
706
|
+
}
|
|
707
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
708
|
+
fail(
|
|
709
|
+
context,
|
|
710
|
+
doc === null
|
|
711
|
+
? "the lockfile is empty"
|
|
712
|
+
: "the lockfile must be a YAML mapping",
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
const version = doc.version;
|
|
716
|
+
if (version === undefined) {
|
|
717
|
+
fail(context, "missing the required version field");
|
|
718
|
+
}
|
|
719
|
+
if (typeof version !== "string" || !SUPPORTED_VERSIONS.has(version)) {
|
|
720
|
+
fail(
|
|
721
|
+
context,
|
|
722
|
+
`unsupported lockfile version '${truncateForWarning(version)}'; supported versions are v0.0.1, v0.0.2 and v0.0.3`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
for (const key of Object.keys(doc)) {
|
|
726
|
+
if (!TOP_LEVEL_FIELDS.has(key)) {
|
|
727
|
+
fail(context, `unknown top-level field '${key}'`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const isV0_0_1 = version === "v0.0.1";
|
|
731
|
+
const entries = parseDependencyEntries(doc, version, context);
|
|
732
|
+
const usesMap = resolveUsesEdges(entries, isV0_0_1, context);
|
|
733
|
+
const cycle = findUsesCycle(usesMap);
|
|
734
|
+
if (cycle) {
|
|
735
|
+
// A crafted file can close a cycle over thousands of entries; keep the
|
|
736
|
+
// warning to the first few hops instead of the whole walk.
|
|
737
|
+
const cyclePath =
|
|
738
|
+
cycle.length > MAX_CYCLE_NODES_IN_WARNING
|
|
739
|
+
? `${cycle.slice(0, MAX_CYCLE_NODES_IN_WARNING).join(" -> ")} -> ...`
|
|
740
|
+
: cycle.join(" -> ");
|
|
741
|
+
fail(context, `uses cycle detected: ${cyclePath}`);
|
|
742
|
+
}
|
|
743
|
+
const workflows = resolveWorkflows(doc, entries, isV0_0_1, context);
|
|
744
|
+
const dependencies = {};
|
|
745
|
+
for (const [key, entry] of entries) {
|
|
746
|
+
dependencies[key] = {
|
|
747
|
+
owner: entry.owner,
|
|
748
|
+
repo: entry.repo,
|
|
749
|
+
ref: entry.ref,
|
|
750
|
+
hashAlgo: entry.hashAlgo,
|
|
751
|
+
commit: entry.commit,
|
|
752
|
+
ownerId: entry.ownerId,
|
|
753
|
+
repoId: entry.repoId,
|
|
754
|
+
hostname: entry.hostname,
|
|
755
|
+
uses: usesMap.get(key) || [],
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
version,
|
|
760
|
+
sourceVersion: isV0_0_1 ? "v0.0.1" : undefined,
|
|
761
|
+
workflows,
|
|
762
|
+
dependencies,
|
|
763
|
+
warnings,
|
|
764
|
+
};
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (!(error instanceof ActionsLockParseAbort)) {
|
|
767
|
+
recordWarning(
|
|
768
|
+
context,
|
|
769
|
+
`unexpected failure while parsing the lockfile (${error?.name || "Error"})`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
return emptyLockResult(warnings);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Read and parse an actions lockfile such as
|
|
778
|
+
* `.github/workflows/actions.lock`.
|
|
779
|
+
*
|
|
780
|
+
* The file is size-checked before it is read, and every problem is reported
|
|
781
|
+
* as a warning and collapses the result to the empty shape; the function
|
|
782
|
+
* never throws.
|
|
783
|
+
*
|
|
784
|
+
* @param {string} filePath Path to the lockfile
|
|
785
|
+
* @returns {ActionsLockfile} Parsed lockfile, or the empty shape
|
|
786
|
+
*/
|
|
787
|
+
export function parseActionsLockfile(filePath) {
|
|
788
|
+
const warnings = [];
|
|
789
|
+
const context = { filePath, warnings };
|
|
790
|
+
if (!safeExistsSync(filePath)) {
|
|
791
|
+
recordWarning(context, "the lockfile does not exist");
|
|
792
|
+
return emptyLockResult(warnings);
|
|
793
|
+
}
|
|
794
|
+
let stats;
|
|
795
|
+
try {
|
|
796
|
+
stats = statSync(filePath);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
recordWarning(
|
|
799
|
+
context,
|
|
800
|
+
`the lockfile could not be inspected (${error?.name || "Error"})`,
|
|
801
|
+
);
|
|
802
|
+
return emptyLockResult(warnings);
|
|
803
|
+
}
|
|
804
|
+
if (!stats.isFile()) {
|
|
805
|
+
recordWarning(context, "the lockfile path is not a regular file");
|
|
806
|
+
return emptyLockResult(warnings);
|
|
807
|
+
}
|
|
808
|
+
if (stats.size > MAX_ACTIONS_LOCKFILE_BYTES) {
|
|
809
|
+
recordWarning(
|
|
810
|
+
context,
|
|
811
|
+
`the lockfile is ${stats.size} bytes, larger than the limit of ${MAX_ACTIONS_LOCKFILE_BYTES} bytes`,
|
|
812
|
+
);
|
|
813
|
+
return emptyLockResult(warnings);
|
|
814
|
+
}
|
|
815
|
+
const text = safeReadFileSync(filePath, "utf-8");
|
|
816
|
+
if (typeof text !== "string") {
|
|
817
|
+
recordWarning(context, "the lockfile could not be read");
|
|
818
|
+
return emptyLockResult(warnings);
|
|
819
|
+
}
|
|
820
|
+
return parseActionsLockfileString(text, filePath);
|
|
821
|
+
}
|