Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions .github/workflows/auto-tag.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
name: CI + Auto Tag
name: CI + Auto Tag & Publish

on:
push:
branches: [main]

jobs:
ci-and-tag:
name: Test, Build & Tag
name: Test, Build, Tag & Publish
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
permissions:
contents: write
id-token: write # npm provenance
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -34,20 +35,35 @@ jobs:
- name: Build
run: npm run build

# Version source of truth is package.json, NOT the latest git tag.
# The old logic read the newest v* tag (v0.0.x) and bumped from there,
# so tags diverged from the package version forever (issue #26).
- name: Tag HEAD with next patch version
id: version
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

# Determine next version from latest git tag (fallback to package.json)
LATEST=$(git tag -l 'v*' --sort=-version:refname | head -1)
if [ -z "$LATEST" ]; then
LATEST="v$(node -p "require('./package.json').version")"
fi
CURRENT="${LATEST#v}"
CURRENT="$(node -p "require('./package.json').version")"
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))"

git tag "v$NEW_VERSION"
git push origin "v$NEW_VERSION"
echo "tagged=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "Tagged HEAD as v$NEW_VERSION"

# Publish in the SAME run. A tag pushed with GITHUB_TOKEN cannot trigger
# publish.yml (GitHub blocks workflow re-entry from token-created events),
# so a separate tag-triggered workflow would never fire (issue #26).
- name: Publish to npm
if: steps.version.outputs.tagged != ''
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "::warning::NPM_TOKEN secret not configured — tag created but npm publish skipped"
exit 0
fi
npm version "${{ steps.version.outputs.tagged }}" --no-git-tag-version
npm publish --provenance --access public
7 changes: 6 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ jobs:

- name: Set version from tag
run: |
# Use tag version if triggered by tag push, otherwise use package.json as-is
# Use tag version if triggered by tag push (keep the two in sync),
# otherwise use package.json as-is for manual dispatch.
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
PKG_VERSION="$(node -p "require('./package.json').version")"
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "warning: tag v$TAG_VERSION != package.json $PKG_VERSION; publishing as $TAG_VERSION" >&2
fi
npm version "$TAG_VERSION" --no-git-tag-version
fi

Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ describe('parseArgs', () => {
it('throws when --fail-on is given without a value', () => {
expect(() => parseArgs(['old.json', 'new.json', '--fail-on'])).toThrow(/Invalid --fail-on/);
});

it('parses --runtime-only as true (default false)', () => {
expect(parseArgs(['old.json', 'new.json']).runtimeOnly).toBe(false);
expect(parseArgs(['old.json', 'new.json', '--runtime-only']).runtimeOnly).toBe(true);
});
});

describe('gateFailures', () => {
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ describe('parse (CycloneDX)', () => {
expect(sbom.components[1].hashes).toBeUndefined();
});

it('extracts the component scope (dev/test vs runtime) (issue #56)', () => {
const sbom = parse({
bomFormat: 'CycloneDX',
specVersion: '1.4',
components: [
{ name: 'runtime-pkg', version: '1.0.0', scope: 'required' },
{ name: 'dev-pkg', version: '1.0.0', scope: 'optional' },
{ name: 'excluded-pkg', version: '1.0.0', scope: 'excluded' },
{ name: 'no-scope-pkg', version: '1.0.0' },
],
});
expect(sbom.components[0].scope).toBe('required');
expect(sbom.components[1].scope).toBe('optional');
expect(sbom.components[2].scope).toBe('excluded');
expect(sbom.components[3].scope).toBeUndefined();
});

it('parses vulnerabilities', () => {
const sbom = parse(cyclonedxFixture);
expect(sbom.vulnerabilities).toHaveLength(1);
Expand Down
48 changes: 39 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,26 @@ Arguments:

Options:
--format <fmt> Output format: text (default), json, or markdown
--fail-on <sev> Fail (exit 3) when a new CVE at/above this severity appears:
none (default), low, medium, high, critical, any
--runtime-only Only consider runtime components (scope=required); dev/test
(scope=optional/excluded) dependencies are filtered out of
the diff and the --fail-on gate
-h, --help Show this help and exit
-v, --version Print the installed version and exit

Examples:
sbom-diff old.json new.json
sbom-diff old.json new.json --format json
sbom-diff old.json new.json --format markdown`;
sbom-diff old.json new.json --format markdown
sbom-diff old.json new.json --runtime-only --fail-on high`;

export interface ParsedArgs {
positional: string[];
format: ReportFormat;
failOn: FailOn;
/** true when --runtime-only was requested (filter dev/test deps) */
runtimeOnly: boolean;
/** true when -h/--help was requested */
help: boolean;
/** true when -v/--version was requested */
Expand All @@ -72,8 +80,8 @@ export interface ParsedArgs {
* the CI/CD gate policy.
*
* Supports `--format text`, `--format=text`, `--fail-on high`, `--fail-on=high`,
* and flags appearing in any position relative to the positional file paths.
* Defaults to `text` format and a `none` gate policy.
* `--runtime-only`, and flags appearing in any position relative to the
* positional file paths. Defaults to `text` format and a `none` gate policy.
*
* `-h`/`--help` and `-v`/`--version` short-circuit parsing so they always
* work — even alongside otherwise-invalid arguments — and never throw.
Expand All @@ -82,15 +90,16 @@ export interface ParsedArgs {
*/
export function parseArgs(argv: string[]): ParsedArgs {
if (argv.some(a => a === '-h' || a === '--help')) {
return { positional: [], format: 'text', failOn: 'none', help: true, version: false };
return { positional: [], format: 'text', failOn: 'none', runtimeOnly: false, help: true, version: false };
}
if (argv.some(a => a === '-v' || a === '-V' || a === '--version')) {
return { positional: [], format: 'text', failOn: 'none', help: false, version: true };
return { positional: [], format: 'text', failOn: 'none', runtimeOnly: false, help: false, version: true };
}

const positional: string[] = [];
let format: ReportFormat = 'text';
let failOn: FailOn = 'none';
let runtimeOnly = false;

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
Expand All @@ -102,14 +111,16 @@ export function parseArgs(argv: string[]): ParsedArgs {
failOn = assertFailOn(argv[++i]);
} else if (arg.startsWith('--fail-on=')) {
failOn = assertFailOn(arg.slice('--fail-on='.length));
} else if (arg === '--runtime-only') {
runtimeOnly = true;
} else if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}\n${USAGE}`);
} else {
positional.push(arg);
}
}

return { positional, format, failOn, help: false, version: false };
return { positional, format, failOn, runtimeOnly, help: false, version: false };
}

/**
Expand Down Expand Up @@ -237,7 +248,7 @@ export async function loadSbom(path: string, label: string): Promise<SBOM> {
}

async function main(): Promise<void> {
const { positional, format, failOn, help, version } = parseArgs(process.argv.slice(2));
const { positional, format, failOn, runtimeOnly, help, version } = parseArgs(process.argv.slice(2));

if (help) {
console.log(HELP);
Expand All @@ -261,11 +272,17 @@ async function main(): Promise<void> {
loadSbom(newPath, 'new'),
]);

const report = diff(oldSBOM, newSBOM);
// With --runtime-only, drop dev/test (scope=optional/excluded) components so
// the diff and the --fail-on gate consider only production dependencies.
// A component without a scope is runtime by CycloneDX's default, so it stays.
const aFinal = runtimeOnly ? filterRuntimeOnly(oldSBOM) : oldSBOM;
const bFinal = runtimeOnly ? filterRuntimeOnly(newSBOM) : newSBOM;

const report = diff(aFinal, bFinal);

console.log(renderReport(report, format));

const warning = gateWarning(oldSBOM, newSBOM, failOn);
const warning = gateWarning(aFinal, bFinal, failOn);
if (warning) console.error(warning);

const failures = gateFailures(report, failOn);
Expand All @@ -278,6 +295,19 @@ async function main(): Promise<void> {
}
}

/**
* Return a copy of the SBOM with only runtime components (those whose scope is
* "required" or unset). Dev/test/build dependencies (scope "optional" or
* "excluded") are filtered out. Vulnerabilities are kept as-is — they reference
* components by ref, and filtering them would misattribute blast radius.
*/
function filterRuntimeOnly(sbom: SBOM): SBOM {
return {
...sbom,
components: sbom.components.filter(c => c.scope === undefined || c.scope === 'required'),
};
}

// Only run when invoked directly (not when imported by tests).
const invokedPath = process.argv[1];
if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
Expand Down
19 changes: 16 additions & 3 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function parseCycloneDX(obj: Record<string, unknown>): SBOM {
license: extractCycloneDXLicense(c),
ecosystem: extractEcosystemFromPurl(typeof c.purl === 'string' ? c.purl : ''),
supplier: extractCycloneDXSupplier(c),
scope: extractCycloneDXScope(c),
hashes: extractCycloneDXHashes(c),
}));

Expand Down Expand Up @@ -239,9 +240,21 @@ function extractSPDXLicense(pkg: Record<string, unknown>): string | undefined {
}

function extractCycloneDXSupplier(c: Record<string, unknown>): string | undefined {
const supplier = c.supplier as Record<string, unknown> | undefined;
if (!supplier) return undefined;
return typeof supplier.name === 'string' ? supplier.name : undefined;
const supplier = c.supplier;
if (typeof supplier !== 'object' || supplier === null) return undefined;
return typeof (supplier as Record<string, unknown>).name === 'string' ? (supplier as Record<string, unknown>).name as string : undefined;
}

/**
* Extract the CycloneDX component scope ("required" / "optional" / "excluded").
* Returns undefined when absent, which is the meaning of "no scope" in CDX:
* scope defaults to "required" when omitted, but we keep it undefined so the
* reporter can show "default" rather than a misleading explicit value.
*/
function extractCycloneDXScope(c: Record<string, unknown>): 'required' | 'optional' | 'excluded' | undefined {
const scope = c.scope;
if (scope === 'required' || scope === 'optional' || scope === 'excluded') return scope;
return undefined;
}

function extractCycloneDXAffects(v: Record<string, unknown>): string[] {
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export interface Component {
ecosystem?: string;
/** Supplier / organization */
supplier?: string;
/**
* CycloneDX component scope: "required" (runtime), "optional"
* (dev/test/build), or "excluded". Lets gates/reports distinguish
* production dependencies from dev/test ones (issue #56).
*/
scope?: 'required' | 'optional' | 'excluded';
/** Hash values keyed by algorithm (sha256, sha1, md5) */
hashes?: Record<string, string>;
}
Expand Down