Skylos records the declared license of each dependency it already inventories for SCA. It writes those licenses into CycloneDX and SPDX 2.3 SBOMs, and it can flag dependencies whose license breaks a policy you set.
Collection works offline by default. Skylos never guesses a license. If it
cannot prove a license from local metadata, it writes NOASSERTION.
skylos sbom . -o sbom.cdx.json # CycloneDX 1.6 (default)
skylos sbom . --format spdx-json -o sbom.spdx.json # SPDX 2.3 JSON
skylos sbom . --format spdx-json --license-lookup # opt-in network lookupExit codes are the same as the CycloneDX export: 0 means the supported
inventory was exported, including when inputs were incomplete (Skylos prints an
SBOM incomplete warning on stderr). 2 means a bad path or a refused or
failed write, or incomplete inputs with --strict. A package with an unknown
license does not make the export incomplete.
| Ecosystem | Offline source (default) | With --license-lookup |
|---|---|---|
npm (package-lock.json, npm-shrinkwrap.json v2/v3) |
The lockfile's packages[...].license, then the installed node_modules/<pkg>/package.json when its name and version match exactly |
deps.dev |
npm (pnpm, Yarn, package.json pins) |
The installed node_modules/<pkg>/package.json when its name and version match exactly. Symlinked installs (the pnpm store) are not followed |
deps.dev |
PyPI (requirements.txt, pyproject.toml, uv, Poetry, Pipfile locks) |
Installed distribution metadata from the project's .venv/venv, or from the Python environment running Skylos. It is used only when the normalized name and version match the locked version. Order of preference: License-Expression, then License, then an unambiguous License :: classifier |
deps.dev |
Go (go.mod) |
None. go.mod and go.sum do not declare licenses, so the value is NOASSERTION |
deps.dev |
Skylos does not support Cargo, Maven, or other ecosystems in the dependency inventory yet, so they have no license data either.
--license-lookup is the only option that makes a network request. It only
queries packages that are still NOASSERTION after the offline pass. Each
request has a 5-second timeout. The whole lookup has a 60-second deadline and a
limit of 1,000 packages. Failed lookups leave the value as NOASSERTION. Scans
(skylos . -a) never run license lookups.
Declared values become SPDX identifiers or expressions only when the mapping is exact:
- SPDX identifiers and expressions are validated and put in canonical case, for
example
mit or apache-2.0becomesMIT OR Apache-2.0. - Deprecated SPDX IDs map to their exact replacements, for example
GPL-3.0becomesGPL-3.0-onlyandGPL-2.0+becomesGPL-2.0-or-later. - A small table covers unambiguous variants, such as
MIT License→MIT,Apache 2.0→Apache-2.0, andNew BSD→BSD-3-Clause. - Legacy npm
{"type": ...}objects are read, andlicensesarrays become anORexpression, as npm documents.
Values that could mean more than one license become NOASSERTION. Examples
are BSD, GPLv3 (it could be -only or -or-later), Apache Software License (no version given), UNLICENSED, and SEE LICENSE IN .... The raw
declared string is kept in the policy finding metadata for review.
components[].licensesholds[{"license": {"id": "MIT"}}]for a single SPDX ID and[{"expression": "MIT OR Apache-2.0"}]for anything else.- If the license is unknown, the component has no
licensesentry. components[].propertiesincludesskylos:license:source, for examplepackage-lock.jsonorpython-metadata:License-Expression.metadata.properties→skylos:licensesis a JSON receipt with the collection mode, declared andNOASSERTIONcounts, counts per source, and lookup statistics.
Sample, trimmed:
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "app-sbom",
"documentNamespace": "https://spdx.skylos.dev/spdxdocs/app-422edcfd…",
"creationInfo": {"created": "2026-09-25T04:32:21Z", "creators": ["Tool: skylos-4.39.1"]},
"packages": [
{"SPDXID": "SPDXRef-RootPackage", "name": "app", "downloadLocation": "NOASSERTION",
"filesAnalyzed": false, "licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION", "copyrightText": "NOASSERTION",
"primaryPackagePurpose": "SOURCE"},
{"SPDXID": "SPDXRef-Package-npm-parent-1.0.0-db4b09d612", "name": "parent",
"versionInfo": "1.0.0", "downloadLocation": "NOASSERTION", "filesAnalyzed": false,
"licenseConcluded": "NOASSERTION", "licenseDeclared": "MIT",
"copyrightText": "NOASSERTION", "primaryPackagePurpose": "LIBRARY",
"externalRefs": [{"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl",
"referenceLocator": "pkg:npm/parent@1.0.0"}]}
],
"relationships": [
{"spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES",
"relatedSpdxElement": "SPDXRef-RootPackage"},
{"spdxElementId": "SPDXRef-RootPackage", "relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Package-npm-parent-1.0.0-db4b09d612"}
]
}licenseDeclaredholds the normalized declared license, orNOASSERTION.licenseConcluded,downloadLocation, andcopyrightTextare alwaysNOASSERTION, because Skylos does not inspect package contents.- Relationships:
DOCUMENT DESCRIBESthe root package. The rootDEPENDS_ONeach direct dependency, meaning a manifest pin or a lockfile direct entry. Package-to-packageDEPENDS_ONedges are written only when the lockfile graph is complete. Any other package is linked from the root asOTHER, with a comment that its path was not recorded. - A
LicenseRef-*declaration adds ahasExtractedLicensingInfosentry. createdfollowsSOURCE_DATE_EPOCHwhen it is set, so builds can be reproducible.documentNamespaceis derived from the package set and the Skylos version.- Inventory completeness and the license receipt are recorded in
creationInfo.comment.
Set the policy in pyproject.toml:
[tool.skylos]
license_deny = ["GPL-*", "AGPL-3.0-only"] # SPDX IDs or globs
license_allow = [] # optional allow-list; empty = allow anything not denied
license_exceptions = ["some-pkg", "other-pkg@1.2.3"] # waived packages
license_severity = "HIGH" # LOW | MEDIUM | HIGH | CRITICAL (default HIGH)The policy runs as part of dependency analysis (skylos . -a, --sca, or
--select SKY-SCA-LIC001). It is offline. Findings go in the
dependency_vulnerabilities result bucket with rule ID SKY-SCA-LIC001 and
category DEPENDENCY. Their metadata includes license, license_source,
license_declared, denied_licenses, and disallowed_licenses. The terminal
report lists them under Dependency Licenses, apart from the per-package
vulnerability table, and each violation counts as its own issue even when the
same package also has advisories (an upgrade does not fix a license).
How the policy is evaluated:
- A package is a violation only when no choice under its expression
avoids the policy.
MIT OR GPL-3.0-onlypasses aGPL-*deny list, andMIT AND GPL-3.0-onlyfails it. - The deny list wins over the allow list. When
license_allowis set, a license that is not on it is a violation. - Entries in the config are normalized the same way as package metadata, so
GPL-3.0in the config matchesGPL-3.0-only. Globs (GPL-*) match without case sensitivity and match the whole ID, soGPL-*does not matchLGPL-*. List-onlyand-or-latervariants separately, or use a glob. NOASSERTIONnever produces a finding. An unknown license is reported in the SBOM, not turned into a violation.- License findings are not captured in dependency baselines. To waive one, use
license_exceptionsor a suppression.
- Project-wide:
ignore = ["SKY-SCA-LIC001"]in[tool.skylos]. - One package: add it to
license_exceptionsasnameorname@version. - Inline: in manifests that allow
#comments, such asrequirements.txt, add# skylos: ignore[SKY-SCA-LIC001]on the dependency's line. JSON lockfiles cannot hold comments, so uselicense_exceptionsfor them.
When .skylos/config.yaml (cloud-synced policy) sets any license_* key, the
synced values take precedence. Repository pyproject.toml cannot relax
license_deny, license_allow, license_exceptions, or license_severity,
and cannot add SKY-SCA-LIC001 to ignore.