diff --git a/app/lib/frontend/handlers/package.dart b/app/lib/frontend/handlers/package.dart index 52e7354c4f..34dded9d10 100644 --- a/app/lib/frontend/handlers/package.dart +++ b/app/lib/frontend/handlers/package.dart @@ -237,6 +237,29 @@ Future packageScoreHandler( ); } +/// Handles requests for: +/// +/// - `/packages//security` +/// - `/packages//versions//security` +Future packageSecurityHandler( + shelf.Request request, + String packageName, { + String? versionName, +}) async { + return await _handlePackagePage( + request: request, + packageName: packageName, + versionName: versionName, + // The page is rendered from the provenance facts persisted with the + // version, it does not need an asset. + assetKind: null, + requiresAdmin: false, + canonicalUrlFn: (p, v) => urls.pkgSecurityUrl(p, version: v), + renderFn: (data) => renderPkgSecurityPage(data), + cacheEntry: cache.uiPackageSecurity(packageName, versionName), + ); +} + /// Handles requests for: /// /// - `/packages//score/log.txt` diff --git a/app/lib/frontend/handlers/routes.dart b/app/lib/frontend/handlers/routes.dart index 00bc2c4e75..305b3ca602 100644 --- a/app/lib/frontend/handlers/routes.dart +++ b/app/lib/frontend/handlers/routes.dart @@ -131,6 +131,13 @@ class PubSiteService { String version, ) => packageScoreHandler(request, package, versionName: version); + @Route.get('/packages//versions//security') + Future packageVersionSecurity( + Request request, + String package, + String version, + ) => packageSecurityHandler(request, package, versionName: version); + @Route.get('/packages//versions//score/log.txt') Future packageVersionScoreLogTxt( Request request, @@ -181,6 +188,10 @@ class PubSiteService { Future packageScore(Request request, String package) => packageScoreHandler(request, package); + @Route.get('/packages//security') + Future packageSecurity(Request request, String package) => + packageSecurityHandler(request, package); + @Route.get('/packages//score/log.txt') Future packageScoreLogTxt(Request request, String package) => packageScoreLogTxtHandler(request, package); diff --git a/app/lib/frontend/handlers/routes.g.dart b/app/lib/frontend/handlers/routes.g.dart index 73572172d1..63ece8aeb5 100644 --- a/app/lib/frontend/handlers/routes.g.dart +++ b/app/lib/frontend/handlers/routes.g.dart @@ -49,6 +49,11 @@ Router _$PubSiteServiceRouter(PubSiteService service) { r'/packages//versions//score', service.packageVersionScore, ); + router.add( + 'GET', + r'/packages//versions//security', + service.packageVersionSecurity, + ); router.add( 'GET', r'/packages//versions//score/log.txt', @@ -72,6 +77,7 @@ Router _$PubSiteServiceRouter(PubSiteService service) { router.add('GET', r'/packages//publisher', service.packagePublisher); router.add('GET', r'/packages//pubspec', service.packagePubspec); router.add('GET', r'/packages//score', service.packageScore); + router.add('GET', r'/packages//security', service.packageSecurity); router.add( 'GET', r'/packages//score/log.txt', diff --git a/app/lib/frontend/templates/package.dart b/app/lib/frontend/templates/package.dart index 4377475c02..2d319c09aa 100644 --- a/app/lib/frontend/templates/package.dart +++ b/app/lib/frontend/templates/package.dart @@ -26,6 +26,7 @@ import 'views/pkg/header.dart'; import 'views/pkg/info_box.dart'; import 'views/pkg/install_tab.dart'; import 'views/pkg/score_tab.dart'; +import 'views/pkg/security_tab.dart'; import 'views/pkg/title_content.dart'; /// Renders the right-side info box (quick summary of the package, mostly coming @@ -132,6 +133,7 @@ d.Node renderPkgHeader(PackagePageData data) { titleNode: titleContentNode( package: package.name!, version: data.version.version!, + slsaLevel: data.version.slsaLevel, ), likeNode: renderLikeButtonAndLabel( package: package.name!, @@ -206,6 +208,15 @@ String renderPkgScorePage(PackagePageData data) { return _renderPkgPage(data: data, tab: .score, tabContent: _scoreTab(data)); } +/// Renders the package security page. +String renderPkgSecurityPage(PackagePageData data) { + return _renderPkgPage( + data: data, + tab: .security, + tabContent: _securityTab(data), + ); +} + final _pageTitleTabIdentifiers = { urls.PkgPageTab.changelog: 'changelog', urls.PkgPageTab.example: 'example', @@ -216,6 +227,7 @@ final _pageTitleTabIdentifiers = { urls.PkgPageTab.admin: 'admin', urls.PkgPageTab.activityLog: 'activity log', urls.PkgPageTab.versions: 'versions', + urls.PkgPageTab.security: 'security', }; String _renderPkgPage({ @@ -493,6 +505,14 @@ Tab _scoreTab(PackagePageData data) { ); } +Tab _securityTab(PackagePageData data) { + return Tab.withContent( + id: 'security', + title: 'Security', + contentNode: securityTabNode(data), + ); +} + d.Node renderPackageSchemaOrgHtml(PackagePageData data) { final p = data.package; final pv = data.version; @@ -573,6 +593,14 @@ List buildPackageTabs({ title: 'Scores', href: urls.pkgScoreUrl(package.name!, version: linkVersion), ); + final hasSecurity = data.version.slsaLevel != null || data.hasAttestation; + final securityTab = + contentIf(.security) ?? + Tab.withLink( + id: 'security', + title: 'Security', + href: urls.pkgSecurityUrl(package.name!, version: linkVersion), + ); final adminTab = contentIf(.admin) ?? Tab.withLink( @@ -596,6 +624,7 @@ List buildPackageTabs({ if (tab == .pubspec) content, versionsTab, scoreTab, + if (hasSecurity || tab == .security) securityTab, if (data.isAdmin) adminTab, if (data.isAdmin) activityLogTab, ]; diff --git a/app/lib/frontend/templates/views/pkg/badge.dart b/app/lib/frontend/templates/views/pkg/badge.dart index 4fdde69b3d..a015a08880 100644 --- a/app/lib/frontend/templates/views/pkg/badge.dart +++ b/app/lib/frontend/templates/views/pkg/badge.dart @@ -19,3 +19,28 @@ d.Node packageBadgeNode({ ], ); } + +/// Renders a shield badge with the SLSA provenance level (e.g. SLSA 2). +d.Node slsaShieldBadgeNode(int level, {String? href}) { + final content = [ + d.unsafeRawHtml( + '$level', + ), + d.text('SLSA $level'), + ]; + final title = + 'Package provenance is verified with SLSA Level $level attestation.'; + if (href != null) { + return d.a( + href: href, + classes: ['package-badge', 'package-badge-slsa'], + attributes: {'title': title}, + children: content, + ); + } + return d.span( + classes: ['package-badge', 'package-badge-slsa'], + attributes: {'title': title}, + children: content, + ); +} diff --git a/app/lib/frontend/templates/views/pkg/info_box.dart b/app/lib/frontend/templates/views/pkg/info_box.dart index 8662e6a6c5..ccf6dc0e94 100644 --- a/app/lib/frontend/templates/views/pkg/info_box.dart +++ b/app/lib/frontend/templates/views/pkg/info_box.dart @@ -15,6 +15,7 @@ import '../../../../shared/urls.dart' as urls; import '../../../../task/backend.dart'; import '../../../dom/dom.dart' as d; import '../shared/images.dart'; +import 'badge.dart'; import 'screenshots.dart'; /// Links inside the package info box. @@ -110,6 +111,7 @@ d.Node packageInfoBoxNode({ ]), ), if (license != null) _block('License', license), + if (version.slsaLevel != null) _provenance(data), if (dependencies != null) _block('Dependencies', dependencies), _more( package.name!, @@ -196,6 +198,26 @@ d.Node _more(String packageName, {required bool showImplementsLink}) { ); } +d.Node _provenance(PackagePageData data) { + final slsaLevel = data.version.slsaLevel!; + final url = urls.pkgSecurityUrl( + data.package.name!, + version: data.isLatestStable ? null : data.version.version, + ); + return _block( + 'Provenance', + d.fragment([ + slsaShieldBadgeNode(slsaLevel, href: url), + d.text(' '), + d.a( + href: url, + text: 'View details', + title: 'View build provenance details on the Security tab', + ), + ]), + ); +} + d.Node _block(String title, d.Node? content) { return d.fragment([ d.h3(classes: ['title'], text: title), diff --git a/app/lib/frontend/templates/views/pkg/security_tab.dart b/app/lib/frontend/templates/views/pkg/security_tab.dart new file mode 100644 index 0000000000..cee6476f08 --- /dev/null +++ b/app/lib/frontend/templates/views/pkg/security_tab.dart @@ -0,0 +1,565 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../../../../package/models.dart'; +import '../../../../shared/urls.dart' as urls; +import '../../../dom/dom.dart' as d; +import 'badge.dart'; + +/// Parsed provenance attestation information. +class AttestationDetails { + final int slsaLevel; + final String? repository; + final String? commit; + final String? ref; + final String? workflow; + final String? runUrl; + final String? signerIdentity; + final String? oidcIssuer; + final String? rekorLogIndex; + final String? rekorUrl; + final DateTime? rekorTimestamp; + final String? archiveSha256; + + AttestationDetails({ + required this.slsaLevel, + this.repository, + this.commit, + this.ref, + this.workflow, + this.runUrl, + this.signerIdentity, + this.oidcIssuer, + this.rekorLogIndex, + this.rekorUrl, + this.rekorTimestamp, + this.archiveSha256, + }); + + /// The details of the attestation of the version in [data], from the + /// provenance facts pub.dev verified and persisted at upload - the bundle + /// itself is not parsed here. + /// + /// Returns `null` if the version was published without an attestation. + static AttestationDetails? fromPageData(PackagePageData data) { + final version = data.version; + final provenance = version.provenance; + final slsaLevel = version.slsaLevel; + if (provenance == null) { + if (slsaLevel == null) { + return null; + } + // Attested, but the facts are not available (e.g. a version from + // before they were persisted): show what we know. + return AttestationDetails( + slsaLevel: slsaLevel, + repository: data.packageLinks.repositoryUrl, + archiveSha256: _hex(version.sha256), + ); + } + final logIndex = provenance.transparencyLogIndex; + return AttestationDetails( + slsaLevel: slsaLevel ?? 2, + repository: provenance.repositoryUrl, + commit: provenance.commitSha, + ref: provenance.ref, + workflow: provenance.workflowPath, + runUrl: provenance.runUrl, + signerIdentity: provenance.signer, + oidcIssuer: provenance.issuer, + rekorLogIndex: logIndex?.toString(), + rekorUrl: logIndex == null + ? null + : 'https://search.sigstore.dev/?logIndex=$logIndex', + rekorTimestamp: provenance.transparencyLogIntegratedTime, + archiveSha256: _hex(version.sha256), + ); + } + + static String? _hex(List? bytes) { + if (bytes == null || bytes.isEmpty) return null; + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } +} + +/// Renders the Security tab content conforming to OpenSSF Level AAA style guide. +d.Node securityTabNode(PackagePageData data) { + final attestation = AttestationDetails.fromPageData(data); + + if (attestation != null) { + return _renderAttestedSecurityTab(data, attestation); + } else { + return _renderUnattestedSecurityTab(data); + } +} + +d.Node _renderAttestedSecurityTab( + PackagePageData data, + AttestationDetails attestation, +) { + final package = data.package.name ?? data.version.package; + final version = data.version.version ?? data.version.id!; + final archiveUrl = urls.pkgArchiveDownloadUrl(package, version); + final attestationUrl = urls.pkgAttestationDownloadUrl(package, version); + + return d.div( + classes: ['security-tab'], + children: [ + d.h2(text: 'Build Provenance'), + _renderStatusCard(attestation), + d.div( + classes: ['security-cards-grid'], + children: [ + _renderSourceCard(attestation), + _renderBuildCard(attestation), + _renderAttestationCard(attestation), + _renderIntegrityCard(package, version, attestation, archiveUrl), + ], + ), + d.div( + classes: ['security-download-section'], + children: [ + d.div( + children: [ + d.a( + classes: ['button', 'button-primary'], + href: attestationUrl, + attributes: {'download': '$package-$version.sigstore.json'}, + children: [ + d.unsafeRawHtml( + '', + ), + d.text('Download attestation bundle (.sigstore.json)'), + ], + ), + ], + ), + d.p( + classes: ['-metadata'], + children: [ + d.text('Learn more about '), + d.a( + href: 'https://slsa.dev', + target: '_blank', + rel: 'noopener noreferrer', + text: 'SLSA specifications', + ), + d.text(' and '), + d.a( + href: 'https://dart.dev/tools/pub/automated-publishing', + target: '_blank', + rel: 'noopener noreferrer', + text: 'automated package publishing', + ), + d.text('.'), + ], + ), + ], + ), + ], + ); +} + +d.Node _renderStatusCard(AttestationDetails attestation) { + return d.div( + classes: ['security-status-card'], + children: [ + d.div( + classes: ['security-status-icon'], + child: d.unsafeRawHtml( + '', + ), + ), + d.div( + classes: ['security-status-content'], + children: [ + d.h3( + children: [ + d.text('Build confirmed'), + d.text(' '), + slsaShieldBadgeNode(attestation.slsaLevel), + ], + ), + d.p( + text: + 'This package version was built and published with automated build provenance verified via Sigstore. ' + 'This cryptographically confirms that the package artifact was produced by the source repository\'s automated CI/CD workflow. ' + 'Provenance verifies where and how this package was built, but does not guarantee that the code is free of bugs, security vulnerabilities, or malicious behavior.', + ), + ], + ), + ], + ); +} + +d.Node _renderSourceCard(AttestationDetails attestation) { + final repo = attestation.repository; + final commit = attestation.commit; + final ref = attestation.ref; + + final repoUri = repo != null ? urls.parseValidUrl(repo) : null; + final isGithub = repoUri != null && repoUri.host == 'github.com'; + + final commitUrl = (isGithub && commit != null) + ? '$repo/commit/$commit' + : null; + + return d.div( + classes: ['security-card'], + children: [ + d.div( + classes: ['security-card-header'], + children: [ + d.unsafeRawHtml( + '', + ), + d.h3(text: 'Source'), + ], + ), + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Repository'), + d.div( + classes: ['security-card-value'], + child: repo != null + ? d.a( + href: repo, + text: repo.replaceFirst(RegExp(r'^https?:\/\/'), ''), + target: '_blank', + rel: 'noopener noreferrer ugc', + ) + : d.text('Not specified'), + ), + ], + ), + if (commit != null) + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Commit'), + d.div( + classes: ['security-card-value'], + child: commitUrl != null + ? d.a( + href: commitUrl, + child: d.code(text: commit.substring(0, 7)), + target: '_blank', + rel: 'noopener noreferrer ugc', + attributes: {'title': commit}, + ) + : d.code(text: commit.substring(0, 7)), + ), + ], + ), + if (ref != null) + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Branch / Tag'), + d.div( + classes: ['security-card-value'], + child: d.code(text: ref), + ), + ], + ), + ], + ); +} + +d.Node _renderBuildCard(AttestationDetails attestation) { + final repo = attestation.repository; + final workflow = attestation.workflow; + final runUrl = attestation.runUrl; + + final repoUri = repo != null ? urls.parseValidUrl(repo) : null; + final isGithub = repoUri != null && repoUri.host == 'github.com'; + + final workflowUrl = (isGithub && workflow != null) + ? '$repo/blob/${attestation.commit ?? attestation.ref ?? "HEAD"}/$workflow' + : null; + + return d.div( + classes: ['security-card'], + children: [ + d.div( + classes: ['security-card-header'], + children: [ + d.unsafeRawHtml( + '', + ), + d.h3(text: 'Build'), + ], + ), + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Platform'), + d.div(classes: ['security-card-value'], text: 'GitHub Actions'), + ], + ), + if (workflow != null) + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Workflow'), + d.div( + classes: ['security-card-value'], + child: workflowUrl != null + ? d.a( + href: workflowUrl, + child: d.code(text: workflow), + target: '_blank', + rel: 'noopener noreferrer ugc', + ) + : d.code(text: workflow), + ), + ], + ), + if (runUrl != null) + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Workflow Run'), + d.div( + classes: ['security-card-value'], + child: d.a( + href: runUrl, + text: 'View build execution on GitHub', + target: '_blank', + rel: 'noopener noreferrer ugc', + attributes: {'title': runUrl}, + ), + ), + ], + ), + ], + ); +} + +d.Node _renderAttestationCard(AttestationDetails attestation) { + final identity = attestation.signerIdentity; + final issuer = attestation.oidcIssuer; + final logIndex = attestation.rekorLogIndex; + final rekorUrl = attestation.rekorUrl; + + return d.div( + classes: ['security-card', 'security-card-full-width'], + children: [ + d.div( + classes: ['security-card-header'], + children: [ + d.unsafeRawHtml( + '', + ), + d.h3(text: 'Attestation & Transparency Log'), + ], + ), + if (identity != null) + d.div( + classes: ['security-card-row'], + children: [ + d.div( + classes: ['security-card-label'], + text: 'Signer Workload Identity', + ), + d.div( + classes: ['security-card-value'], + child: d.code(text: identity), + ), + ], + ), + if (issuer != null) + d.div( + classes: ['security-card-row'], + children: [ + d.div( + classes: ['security-card-label'], + text: 'Identity Provider (OIDC)', + ), + d.div( + classes: ['security-card-value'], + child: d.code(text: issuer), + ), + ], + ), + d.div( + classes: ['security-card-row'], + children: [ + d.div( + classes: ['security-card-label'], + text: 'Transparency Log (Rekor)', + ), + d.div( + classes: ['security-card-value'], + child: rekorUrl != null + ? d.fragment([ + d.a( + href: rekorUrl, + text: 'Entry #$logIndex', + target: '_blank', + rel: 'noopener noreferrer', + attributes: {'title': rekorUrl}, + ), + if (attestation.rekorTimestamp != null) ...[ + d.text(' (recorded '), + d.xAgoTimestamp(attestation.rekorTimestamp!), + d.text(')'), + ], + ]) + : d.text('Recorded in Sigstore public transparency log'), + ), + ], + ), + d.div( + classes: ['security-card-row'], + children: [ + d.div( + classes: ['security-card-label'], + text: 'Certificate Authority', + ), + d.div(classes: ['security-card-value'], text: 'Sigstore (Fulcio)'), + ], + ), + ], + ); +} + +d.Node _renderIntegrityCard( + String package, + String version, + AttestationDetails attestation, + String archiveUrl, +) { + final sha256 = attestation.archiveSha256; + + return d.div( + classes: ['security-card', 'security-card-full-width'], + children: [ + d.div( + classes: ['security-card-header'], + children: [ + d.unsafeRawHtml( + '', + ), + d.h3(text: 'Package Integrity'), + ], + ), + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Archive File'), + d.div( + classes: ['security-card-value'], + child: d.a(href: archiveUrl, text: '$package-$version.tar.gz'), + ), + ], + ), + if (sha256 != null) + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'SHA-256 Checksum'), + d.div( + classes: ['security-card-value'], + child: d.codeSnippet( + language: 'plaintext', + textToCopy: sha256, + text: sha256, + ), + ), + ], + ), + ], + ); +} + +d.Node _renderUnattestedSecurityTab(PackagePageData data) { + final package = data.package.name ?? data.version.package; + final version = data.version.version ?? data.version.id!; + final archiveUrl = urls.pkgArchiveDownloadUrl(package, version); + + return d.div( + classes: ['security-tab'], + children: [ + d.h2(text: 'Build Provenance'), + d.div( + classes: ['markdown-alert', 'markdown-alert-note'], + children: [ + d.p( + classes: ['markdown-alert-title'], + text: 'No build provenance attestation available', + ), + d.p( + text: + 'This version was published without cryptographic build provenance. ' + 'Build provenance provides verifiable proof connecting a package artifact directly to its source repository and automated build workflow.', + ), + d.p( + children: [ + d.text( + 'Package publishers can establish build provenance by publishing through automated CI/CD workflows. ', + ), + d.a( + href: 'https://dart.dev/tools/pub/automated-publishing', + target: '_blank', + rel: 'noopener noreferrer', + text: + 'Learn how to automate publishing with provenance on dart.dev.', + ), + ], + ), + ], + ), + d.div( + classes: ['security-cards-grid'], + children: [ + d.div( + classes: ['security-card', 'security-card-full-width'], + children: [ + d.div( + classes: ['security-card-header'], + children: [ + d.unsafeRawHtml( + '', + ), + d.h3(text: 'Package Archive'), + ], + ), + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Archive File'), + d.div( + classes: ['security-card-value'], + child: d.a( + href: archiveUrl, + text: '$package-$version.tar.gz', + ), + ), + ], + ), + if (data.packageLinks.repositoryUrl != null) + d.div( + classes: ['security-card-row'], + children: [ + d.div(classes: ['security-card-label'], text: 'Repository'), + d.div( + classes: ['security-card-value'], + child: d.a( + href: data.packageLinks.repositoryUrl!, + text: data.packageLinks.repositoryUrl!, + target: '_blank', + rel: 'noopener noreferrer ugc', + ), + ), + ], + ), + ], + ), + ], + ), + ], + ); +} diff --git a/app/lib/frontend/templates/views/pkg/title_content.dart b/app/lib/frontend/templates/views/pkg/title_content.dart index 5cf09ebfb1..95e4337cc4 100644 --- a/app/lib/frontend/templates/views/pkg/title_content.dart +++ b/app/lib/frontend/templates/views/pkg/title_content.dart @@ -2,13 +2,26 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import '../../../../shared/urls.dart' as urls; import '../../../dom/dom.dart' as d; import '../../../static_files.dart'; +import 'badge.dart'; -d.Node titleContentNode({required String package, required String version}) { +d.Node titleContentNode({ + required String package, + required String version, + int? slsaLevel, +}) { return d.fragment([ d.text('$package $version '), copyIcon(package: package, version: version), + if (slsaLevel != null) ...[ + d.text(' '), + slsaShieldBadgeNode( + slsaLevel, + href: urls.pkgSecurityUrl(package, version: version), + ), + ], ]); } diff --git a/app/lib/frontend/templates/views/pkg/versions/version_row.dart b/app/lib/frontend/templates/views/pkg/versions/version_row.dart index b0e9cff84b..f67b44a50b 100644 --- a/app/lib/frontend/templates/views/pkg/versions/version_row.dart +++ b/app/lib/frontend/templates/views/pkg/versions/version_row.dart @@ -9,6 +9,7 @@ import '../../../../../package/model_properties.dart'; import '../../../../../shared/urls.dart' as urls; import '../../../../dom/dom.dart' as d; import '../../../../static_files.dart'; +import '../badge.dart'; d.Node versionRowNode( String package, @@ -28,7 +29,15 @@ d.Node versionRowNode( title: 'Visit $package ${version.version} page', ), ), - d.td(classes: ['badge'], child: null), + d.td( + classes: ['badge'], + child: version.slsaLevel != null + ? slsaShieldBadgeNode( + version.slsaLevel!, + href: urls.pkgSecurityUrl(package, version: version.version), + ) + : null, + ), d.td( classes: ['sdk'], child: sdk != null diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 145495eda2..bf467be33e 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -2683,6 +2683,7 @@ extension PackageVersionExt on PackageVersion { ), archiveSha256: archiveSha256, published: created, + slsaLevel: slsaLevel, ); } } @@ -2730,6 +2731,7 @@ Future purgePackageCache(String package) async { cache.uiPackageExample(package, null).purge(), cache.uiPackageInstall(package, null).purge(), cache.uiPackageScore(package, null).purge(), + cache.uiPackageSecurity(package, null).purge(), cache.uiPackageVersions(package).purge(), cache.uiLandingPageContent().purge(), cache.allPackagesAtomFeedXml().purge(), @@ -2886,7 +2888,10 @@ Future<_UploadEntities> _createUploadEntities( ..libraries = archive.libraries ..uploader = agent.agentId ..sha256 = sha256Hash - ..provenance = provenance; + ..provenance = provenance + // A verified build provenance from a trusted builder on a hosted runner + // is SLSA Build Level 2. + ..slsaLevel = provenance != null ? 2 : null; final derived = derivePackageVersionEntities( archive: archive, diff --git a/app/lib/package/models.dart b/app/lib/package/models.dart index 71c30a8744..b9603248e6 100644 --- a/app/lib/package/models.dart +++ b/app/lib/package/models.dart @@ -698,6 +698,10 @@ class PackageVersion extends db.ExpandoModel { @db.DateTimeProperty() DateTime? retracted; + /// The SLSA provenance level (e.g. 2) if this version is attested. + @db.IntProperty() + int? slsaLevel; + /// `true` if package version was moderated (pending moderation or deletion). @db.BoolProperty(required: true) bool isModerated = false; @@ -1250,6 +1254,9 @@ class PackagePageData { bool get hasLicense => assets.contains(AssetKind.license); bool get hasPubspec => assets.contains(AssetKind.pubspec); + /// Whether the version was published with a (verified) attestation. + bool get hasAttestation => version.provenance != null; + bool get isLatestStable => version.version == package.latestVersion; late final packageLinks = () { diff --git a/app/lib/shared/redis_cache.dart b/app/lib/shared/redis_cache.dart index 634f7ecaec..268f07163c 100644 --- a/app/lib/shared/redis_cache.dart +++ b/app/lib/shared/redis_cache.dart @@ -104,6 +104,11 @@ class CachePatterns { .withTTL(Duration(minutes: 10)) .withCodec(utf8)['$package-$version']; + Entry uiPackageSecurity(String package, String? version) => _cache + .withPrefix('ui-package-security/') + .withTTL(Duration(minutes: 10)) + .withCodec(utf8)['$package-$version']; + Entry uiPackageVersions(String package) => _cache .withPrefix('ui-package-versions/') .withTTL(Duration(minutes: 10)) diff --git a/app/lib/shared/urls.dart b/app/lib/shared/urls.dart index 477457283e..572662b798 100644 --- a/app/lib/shared/urls.dart +++ b/app/lib/shared/urls.dart @@ -41,6 +41,7 @@ enum PkgPageTab { score, admin, activityLog, + security, } const _pkgPageTabSegments = { @@ -54,6 +55,7 @@ const _pkgPageTabSegments = { PkgPageTab.score: 'score', PkgPageTab.admin: 'admin', PkgPageTab.activityLog: 'activity-log', + PkgPageTab.security: 'security', }; String pkgPageUrl( @@ -115,6 +117,9 @@ String pkgAdminUrl(String package, {bool? includeHost}) => pkgPageUrl( String pkgActivityLogUrl(String package) => pkgPageUrl(package, pkgPageTab: PkgPageTab.activityLog); +String pkgSecurityUrl(String package, {String? version}) => + pkgPageUrl(package, version: version, pkgPageTab: PkgPageTab.security); + String pkgArchiveDownloadUrl(String package, String version, {Uri? baseUri}) { final p = Uri.encodeComponent(package); final v = Uri.encodeComponent(version); @@ -126,6 +131,12 @@ String pkgArchiveDownloadUrl(String package, String version, {Uri? baseUri}) { } } +String pkgAttestationDownloadUrl(String package, String version) { + final p = Uri.encodeComponent(package); + final v = Uri.encodeComponent(version); + return '/api/packages/$p/versions/$v/attestation'; +} + String pkgFeedUrl(String package) { return '/api/packages/$package/feed.atom'; } diff --git a/app/test/frontend/handlers/invalid_package_url_test.dart b/app/test/frontend/handlers/invalid_package_url_test.dart index 9c49005ac5..518639ce78 100644 --- a/app/test/frontend/handlers/invalid_package_url_test.dart +++ b/app/test/frontend/handlers/invalid_package_url_test.dart @@ -34,7 +34,7 @@ void main() { expect(urls, contains('/packages//versions/')); expect(urls, contains('/api/packages/')); // this a naive assertion that fails, if new end-points are introduced! - expect(urls, hasLength(45), reason: 'check if new end-points was added'); + expect(urls, hasLength(47), reason: 'check if new end-points was added'); }); testWithProfile( diff --git a/app/test/frontend/handlers/package_test.dart b/app/test/frontend/handlers/package_test.dart index 356c6ffe85..53d2af38d7 100644 --- a/app/test/frontend/handlers/package_test.dart +++ b/app/test/frontend/handlers/package_test.dart @@ -52,6 +52,14 @@ void main() { await issueGet('/packages/oxygen/versions/1.2.00/changelog'), '/packages/oxygen/versions/1.2.0/changelog', ); + await expectRedirectResponse( + await issueGet('/packages/oxYgen/security'), + '/packages/oxygen/security', + ); + await expectRedirectResponse( + await issueGet('/packages/oxYgen/versions/1.2.0/security'), + '/packages/oxygen/versions/1.2.0/security', + ); }, ); @@ -182,6 +190,7 @@ void main() { '/packages/pkg/pubspec', '/packages/pkg/license', '/packages/pkg/score', + '/packages/pkg/security', ]; for (final url in urls) { await expectHtmlResponse( @@ -193,6 +202,35 @@ void main() { }, ); + testWithProfile( + '/packages/oxygen/security - found', + fn: () async { + await expectHtmlResponse( + await issueGet('/packages/oxygen/security'), + present: [ + 'oxygen 1.2.0', + 'Build Provenance', + 'No build provenance attestation available', + 'Learn how to automate publishing with provenance on dart.dev', + ], + ); + }, + ); + + testWithProfile( + '/packages/oxygen/versions/1.2.0/security - found', + fn: () async { + await expectHtmlResponse( + await issueGet('/packages/oxygen/versions/1.2.0/security'), + present: [ + 'oxygen 1.2.0', + 'Build Provenance', + 'No build provenance attestation available', + ], + ); + }, + ); + testWithProfile( 'publisher redirect', fn: () async { diff --git a/app/test/frontend/security_tab_test.dart b/app/test/frontend/security_tab_test.dart new file mode 100644 index 0000000000..de5fa30b0e --- /dev/null +++ b/app/test/frontend/security_tab_test.dart @@ -0,0 +1,286 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:gcloud/db.dart' as db; +import 'package:pub_dev/frontend/dom/dom.dart' as d; +import 'package:pub_dev/frontend/templates/views/pkg/badge.dart'; +import 'package:pub_dev/frontend/templates/views/pkg/info_box.dart'; +import 'package:pub_dev/frontend/templates/views/pkg/security_tab.dart'; +import 'package:pub_dev/package/model_properties.dart'; +import 'package:pub_dev/package/models.dart'; +import 'package:pub_dev/scorecard/models.dart'; +import 'package:pub_dev/shared/urls.dart' as urls; +import 'package:test/test.dart'; + +/// The provenance facts of the sample attestation, as persisted at upload. +final _sampleProvenance = VersionProvenance( + repository: 'sigstore-conformance/extremely-dangerous-public-oidc-beacon', + repositoryId: '632596897', + repositoryOwnerId: '131804563', + workflowPath: '.github/workflows/extremely-dangerous-oidc-beacon.yml', + ref: 'refs/heads/main', + commitSha: 'c7b3dfb335f051e1c86bda4c716fac97df62ad81', + runUrl: + 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/actions/runs/8347481628/attempts/1', + signer: + 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/.github/workflows/extremely-dangerous-oidc-beacon.yml@refs/heads/main', + issuer: 'https://token.actions.githubusercontent.com', + transparencyLogIndex: 79571823, + transparencyLogIntegratedTime: DateTime.utc(2024, 3, 19, 17, 26, 26), +); + +final _sampleSha256 = List.generate( + 32, + (i) => int.parse( + 'a0cfc71271d6e278e57cd332ff957c3f7043fdda354c4cbb190a30d56efa01bf' + .substring(i * 2, i * 2 + 2), + radix: 16, + ), +); + +PackagePageData _createMockData({ + int? slsaLevel, + VersionProvenance? provenance, +}) { + final pubspec = Pubspec.fromYaml(''' +name: test_pkg +version: 1.0.0 +description: A package for testing. +repository: https://github.com/example/test_pkg +'''); + + final partition = db.Partition(null); + final rootKey = db.Key.emptyKey(partition); + final packageKey = rootKey.append(Package, id: 'test_pkg'); + + final version = PackageVersion.init() + ..id = '1.0.0' + ..version = '1.0.0' + ..parentKey = packageKey + ..packageKey = packageKey + ..created = DateTime.utc(2026, 1, 1) + ..pubspec = pubspec + ..uploader = 'test@example.com' + ..sha256 = _sampleSha256 + ..slsaLevel = slsaLevel + ..provenance = provenance; + + final package = Package.fromVersion(version); + + return PackagePageData( + package: package, + version: version, + assets: [AssetKind.pubspec, AssetKind.readme], + asset: null, + scoreCard: ScoreCardData(), + isAdmin: false, + isLiked: false, + weeklyDownloadCounts: null, + ); +} + +void main() { + group('AttestationDetails', () { + test('is built from the persisted provenance facts', () { + final data = _createMockData(slsaLevel: 2, provenance: _sampleProvenance); + final details = AttestationDetails.fromPageData(data); + + expect(details, isNotNull); + expect(details!.slsaLevel, 2); + expect(details.rekorLogIndex, '79571823'); + expect( + details.rekorUrl, + 'https://search.sigstore.dev/?logIndex=79571823', + ); + expect(details.rekorTimestamp, DateTime.utc(2024, 3, 19, 17, 26, 26)); + expect( + details.archiveSha256, + 'a0cfc71271d6e278e57cd332ff957c3f7043fdda354c4cbb190a30d56efa01bf', + ); + expect( + details.repository, + 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon', + ); + expect( + details.workflow, + '.github/workflows/extremely-dangerous-oidc-beacon.yml', + ); + expect(details.ref, 'refs/heads/main'); + expect(details.commit, 'c7b3dfb335f051e1c86bda4c716fac97df62ad81'); + expect( + details.runUrl, + 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/actions/runs/8347481628/attempts/1', + ); + expect( + details.signerIdentity, + 'https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/.github/workflows/extremely-dangerous-oidc-beacon.yml@refs/heads/main', + ); + expect(details.oidcIssuer, 'https://token.actions.githubusercontent.com'); + }); + + test('falls back to the package links without persisted facts', () { + final data = _createMockData(slsaLevel: 2); + final details = AttestationDetails.fromPageData(data); + expect(details, isNotNull); + expect(details!.slsaLevel, 2); + expect(details.repository, 'https://github.com/example/test_pkg'); + expect(details.signerIdentity, isNull); + expect( + details.archiveSha256, + 'a0cfc71271d6e278e57cd332ff957c3f7043fdda354c4cbb190a30d56efa01bf', + ); + }); + + test('returns null when the version is not attested', () { + final data = _createMockData(slsaLevel: null); + expect(AttestationDetails.fromPageData(data), isNull); + }); + }); + + group('securityTabNode UI rendering', () { + test('renders full Level AAA provenance details when attestation exists', () { + final data = _createMockData(slsaLevel: 2, provenance: _sampleProvenance); + final node = securityTabNode(data); + final html = node.toString(); + + // Heading and summary + expect(html, contains('

Build Provenance

')); + expect(html, contains('Build confirmed')); + expect(html, contains('SLSA 2')); + expect(html, contains('package-badge-slsa')); + + // Source card + expect(html, contains('Source')); + expect( + html, + contains( + 'github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon', + ), + ); + expect(html, contains('c7b3dfb')); + expect(html, contains('refs/heads/main')); + + // Build card + expect(html, contains('Build')); + expect(html, contains('GitHub Actions')); + expect( + html, + contains( + '.github/workflows/extremely-dangerous-oidc-beacon.yml', + ), + ); + expect(html, contains('actions/runs/8347481628')); + + // Attestation card + expect(html, contains('Attestation & Transparency Log')); + expect(html, contains('token.actions.githubusercontent.com')); + expect(html, contains('https://search.sigstore.dev/?logIndex=79571823')); + expect(html, contains('Sigstore (Fulcio)')); + + // Integrity card + expect(html, contains('Package Integrity')); + expect(html, contains('test_pkg-1.0.0.tar.gz')); + expect( + html, + contains( + 'a0cfc71271d6e278e57cd332ff957c3f7043fdda354c4cbb190a30d56efa01bf', + ), + ); + + // Download section + expect(html, contains('Download attestation bundle (.sigstore.json)')); + expect( + html, + contains('/api/packages/test_pkg/versions/1.0.0/attestation'), + ); + + // Disclaimer and accessibility tooltips + expect( + html, + contains( + 'does not guarantee that the code is free of bugs, security vulnerabilities, or malicious behavior', + ), + ); + expect( + html, + contains('title="https://search.sigstore.dev/?logIndex=79571823"'), + ); + expect( + html, + contains( + 'title="https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/actions/runs/8347481628/attempts/1"', + ), + ); + }); + + test('renders educational empty state when no attestation exists', () { + final data = _createMockData(slsaLevel: null); + final node = securityTabNode(data); + final html = node.toString(); + + expect(html, contains('

Build Provenance

')); + expect(html, contains('No build provenance attestation available')); + expect(html, contains('https://dart.dev/tools/pub/automated-publishing')); + expect(html, contains('test_pkg-1.0.0.tar.gz')); + }); + }); + + group('slsaShieldBadgeNode', () { + test('renders span when href is null', () { + final node = slsaShieldBadgeNode(2); + final html = node.toString(); + expect( + html, + startsWith('')); + expect(html, contains('SLSA 2')); + expect(html, contains('href="/packages/test_pkg/security"')); + expect(html, contains('View details')); + }); + + test('does not render provenance link when slsaLevel is null', () { + final data = _createMockData(slsaLevel: null); + final node = packageInfoBoxNode( + data: data, + metaLinks: [], + docLinks: [], + fundingLinks: [], + labeledScores: d.div(), + ); + final html = node.toString(); + expect(html.contains('Provenance'), isFalse); + }); + }); +} diff --git a/pkg/_pub_shared/lib/data/package_api.dart b/pkg/_pub_shared/lib/data/package_api.dart index 52510db8f7..9bcae68715 100644 --- a/pkg/_pub_shared/lib/data/package_api.dart +++ b/pkg/_pub_shared/lib/data/package_api.dart @@ -278,6 +278,10 @@ class VersionInfo { /// This is an optional field of the API response, it may be `null` or omitted. final DateTime? published; + /// The SLSA provenance level if the version has an attestation (e.g. 2). + @JsonKey(name: 'slsa_level') + final int? slsaLevel; + VersionInfo({ required this.version, required this.retracted, @@ -285,6 +289,7 @@ class VersionInfo { required this.archiveUrl, required this.archiveSha256, required this.published, + this.slsaLevel, }); factory VersionInfo.fromJson(Map json) => diff --git a/pkg/_pub_shared/lib/data/package_api.g.dart b/pkg/_pub_shared/lib/data/package_api.g.dart index c69695d24b..39263d9917 100644 --- a/pkg/_pub_shared/lib/data/package_api.g.dart +++ b/pkg/_pub_shared/lib/data/package_api.g.dart @@ -179,6 +179,7 @@ VersionInfo _$VersionInfoFromJson(Map json) => VersionInfo( published: json['published'] == null ? null : DateTime.parse(json['published'] as String), + slsaLevel: (json['slsa_level'] as num?)?.toInt(), ); Map _$VersionInfoToJson(VersionInfo instance) => @@ -189,6 +190,7 @@ Map _$VersionInfoToJson(VersionInfo instance) => 'archive_url': ?instance.archiveUrl, 'archive_sha256': ?instance.archiveSha256, 'published': ?instance.published?.toIso8601String(), + 'slsa_level': ?instance.slsaLevel, }; VersionScore _$VersionScoreFromJson(Map json) => VersionScore( diff --git a/pkg/web_css/lib/src/_pkg.scss b/pkg/web_css/lib/src/_pkg.scss index 886459250f..11c300d412 100644 --- a/pkg/web_css/lib/src/_pkg.scss +++ b/pkg/web_css/lib/src/_pkg.scss @@ -20,6 +20,12 @@ color: var(--pub-badge-red-color); } + &.package-badge-slsa { + border-color: var(--pub-badge-default-color); + color: var(--pub-badge-default-color); + font-weight: 500; + } + &.package-badge-name-match { position: relative; top: -5px; @@ -605,3 +611,134 @@ .license-op-match { background: var(--pub-license-editop-match); } + +.security-tab { + margin-top: 16px; + + .security-status-card { + background: var(--pub-inset-bgColor); + border: 1px solid var(--pub-neutral-borderColor); + border-radius: 8px; + padding: 20px; + margin-bottom: 24px; + display: flex; + align-items: flex-start; + gap: 16px; + + .security-status-icon { + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--pub-security-status-icon-bgColor); + + svg { + display: block; + } + } + + .security-status-content { + flex-grow: 1; + + h3 { + margin: 0 0 8px 0; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + } + + p { + margin: 0; + font-size: 14px; + line-height: 1.5; + } + } + } + + .security-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 24px; + } + + .security-card { + background: var(--pub-neutral-bgColor); + border: 1px solid var(--pub-neutral-borderColor); + border-radius: 8px; + padding: 20px; + + &.security-card-full-width { + grid-column: 1 / -1; + } + + .security-card-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--pub-neutral-borderColor); + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + } + + .security-card-icon { + width: 20px; + height: 20px; + color: var(--pub-badge-default-color); + flex-shrink: 0; + } + } + + .security-card-row { + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } + + .security-card-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.7; + margin-bottom: 4px; + } + + .security-card-value { + font-size: 14px; + word-break: break-word; + + code { + font-size: 13px; + word-break: break-all; + } + + pre { + margin: 4px 0 0 0; + } + } + } + } + + .security-download-section { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--pub-neutral-borderColor); + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + } +} diff --git a/pkg/web_css/lib/src/_variables.scss b/pkg/web_css/lib/src/_variables.scss index f9d2816c6f..3460fc1ba3 100644 --- a/pkg/web_css/lib/src/_variables.scss +++ b/pkg/web_css/lib/src/_variables.scss @@ -135,6 +135,7 @@ --pub-badge-red-color: var(--pub-color-dangerRed); --pub-button-danger-background: var(--pub-color-dangerRed); --pub-copy_feedback-background-color: #fafaff; + --pub-security-status-icon-bgColor: #e8f5e9; --pub-detail_tab-text-color: var(--pub-neutral-textColor); --pub-detail_tab-underline-color: #dddddd; --pub-detail_tab-active-color: var(--pub-link-text-color); @@ -194,6 +195,7 @@ --pub-badge-red-color: var(--pub-color-dangerRed); --pub-button-danger-background: #a03737; --pub-copy_feedback-background-color: #404040; + --pub-security-status-icon-bgColor: #1e3a23; --pub-detail_tab-text-color: var(--pub-neutral-textColor); --pub-detail_tab-underline-color: #888888; --pub-detail_tab-active-color: var(--pub-link-text-color);