diff --git a/README.md b/README.md index 532c8259..e7c4ef3a 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Since version 0.6.0 of Node-secure the UI include a brand new searchbar that all - author (author name/email/url). - ext (list of available file extensions in the current payload/tree). - builtin (available Node.js core module name). -- size (see [here](https://github.com/NodeSecure/size-satisfies#usage-example) +- size (see [here](https://github.com/NodeSecure/size-satisfies#usage-example)). Exemple of query: @@ -187,7 +187,6 @@ other side will bundle and remove most of the useless files from the tarball (Li ### Why some packages don't have OSSF Scorecard ? See [Scorecard Public Data](https://github.com/ossf/scorecard#public-data): > We run a weekly Scorecard scan of the 1 million most critical open source projects judged by their direct dependencies and publish the results in a BigQuery public dataset. -> Currently, this list is derived from projects hosted on GitHub ONLY. ## Contributors guide diff --git a/bin/index.js b/bin/index.js index b94e1316..7258c9c2 100755 --- a/bin/index.js +++ b/bin/index.js @@ -83,6 +83,7 @@ prog prog .command("scorecard [repository]") .describe(i18n.getTokenSync("cli.commands.scorecard.desc")) + .option("--vcs", "Version control platform (GitHub, GitLab", "github") .action(commands.scorecard.main); prog diff --git a/package.json b/package.json index a7ebe1ae..56083297 100644 --- a/package.json +++ b/package.json @@ -1,108 +1,108 @@ -{ - "name": "@nodesecure/cli", - "version": "2.2.1", - "description": "Node.js security CLI", - "main": "./bin/index.js", - "bin": { - "node-secure": "./bin/index.js", - "nsecure": "./bin/index.js" - }, - "type": "module", - "engines": { - "node": ">=18" - }, - "scripts": { - "eslint": "eslint bin src test", - "eslint-fix": "npm run eslint -- --fix", - "prepublishOnly": "rimraf ./dist && npm run build && pkg-ok", - "build": "node ./esbuild.config.js", - "test": "npm run test-only && npm run eslint", - "test-only": "node --loader=esmock --no-warnings --test test/", - "coverage": "c8 --reporter=lcov npm run test" - }, - "files": [ - "bin", - "dist", - "src", - "views" - ], - "workspaces": [ - "workspaces/documentation-ui", - "workspaces/vis-network" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/NodeSecure/cli.git" - }, - "keywords": [ - "node", - "nodejs", - "security", - "cli", - "sast", - "scanner", - "static", - "code", - "analysis", - "node_modules", - "tree", - "npm", - "registry", - "graph", - "visualization", - "dependencies" - ], - "author": "GENTILHOMME Thomas ", - "license": "MIT", - "bugs": { - "url": "https://github.com/NodeSecure/cli/issues" - }, - "homepage": "https://github.com/NodeSecure/cli#readme", - "devDependencies": { - "@myunisoft/httpie": "^2.0.1", - "@nodesecure/eslint-config": "^1.7.1", - "@nodesecure/size-satisfies": "^1.1.0", - "@nodesecure/vis-network": "^1.4.0", - "@types/node": "^20.5.3", - "c8": "^8.0.1", - "cross-env": "^7.0.3", - "esbuild": "^0.19.2", - "eslint": "^8.47.0", - "esmock": "^2.3.8", - "http-server": "^14.1.1", - "pkg-ok": "^3.0.0", - "pretty-bytes": "^6.1.1", - "rimraf": "^5.0.5", - "strip-ansi": "^7.1.0" - }, - "dependencies": { - "@nodesecure/documentation-ui": "^1.3.0", - "@nodesecure/flags": "^2.4.0", - "@nodesecure/i18n": "^3.4.0", - "@nodesecure/licenses-conformance": "^2.1.0", - "@nodesecure/npm-registry-sdk": "^1.6.1", - "@nodesecure/ossf-scorecard-sdk": "^2.0.0", - "@nodesecure/rc": "^1.5.0", - "@nodesecure/scanner": "^5.1.0", - "@nodesecure/utils": "^1.1.0", - "@nodesecure/vuln": "^1.7.0", - "@openally/result": "^1.2.0", - "@polka/send-type": "^0.5.2", - "@topcli/cliui": "^1.1.0", - "@topcli/spinner": "^2.1.2", - "cacache": "^18.0.0", - "dotenv": "^16.3.1", - "filenamify": "^6.0.0", - "ini": "^4.1.1", - "kleur": "^4.1.5", - "ms": "^2.1.3", - "open": "^9.1.0", - "polka": "^0.5.2", - "qoa": "^0.2.0", - "sade": "^1.8.1", - "semver": "^7.5.4", - "server-destroy": "^1.0.1", - "sirv": "^2.0.3", - "zup": "0.0.1" - } -} +{ + "name": "@nodesecure/cli", + "version": "2.2.1", + "description": "Node.js security CLI", + "main": "./bin/index.js", + "bin": { + "node-secure": "./bin/index.js", + "nsecure": "./bin/index.js" + }, + "type": "module", + "engines": { + "node": ">=18" + }, + "scripts": { + "eslint": "eslint bin src test", + "eslint-fix": "npm run eslint -- --fix", + "prepublishOnly": "rimraf ./dist && npm run build && pkg-ok", + "build": "node ./esbuild.config.js", + "test": "npm run test-only && npm run eslint", + "test-only": "node --loader=esmock --no-warnings --test test/", + "coverage": "c8 --reporter=lcov npm run test" + }, + "files": [ + "bin", + "dist", + "src", + "views" + ], + "workspaces": [ + "workspaces/documentation-ui", + "workspaces/vis-network" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/NodeSecure/cli.git" + }, + "keywords": [ + "node", + "nodejs", + "security", + "cli", + "sast", + "scanner", + "static", + "code", + "analysis", + "node_modules", + "tree", + "npm", + "registry", + "graph", + "visualization", + "dependencies" + ], + "author": "GENTILHOMME Thomas ", + "license": "MIT", + "bugs": { + "url": "https://github.com/NodeSecure/cli/issues" + }, + "homepage": "https://github.com/NodeSecure/cli#readme", + "devDependencies": { + "@myunisoft/httpie": "^2.0.1", + "@nodesecure/eslint-config": "^1.7.1", + "@nodesecure/size-satisfies": "^1.1.0", + "@nodesecure/vis-network": "^1.4.0", + "@types/node": "^20.5.3", + "c8": "^8.0.1", + "cross-env": "^7.0.3", + "esbuild": "^0.19.2", + "eslint": "^8.47.0", + "esmock": "^2.3.8", + "http-server": "^14.1.1", + "pkg-ok": "^3.0.0", + "pretty-bytes": "^6.1.1", + "rimraf": "^5.0.5", + "strip-ansi": "^7.1.0" + }, + "dependencies": { + "@nodesecure/documentation-ui": "^1.3.0", + "@nodesecure/flags": "^2.4.0", + "@nodesecure/i18n": "^3.4.0", + "@nodesecure/licenses-conformance": "^2.1.0", + "@nodesecure/npm-registry-sdk": "^1.6.1", + "@nodesecure/ossf-scorecard-sdk": "^3.1.0", + "@nodesecure/rc": "^1.5.0", + "@nodesecure/scanner": "^5.1.0", + "@nodesecure/utils": "^1.1.0", + "@nodesecure/vuln": "^1.7.0", + "@openally/result": "^1.2.0", + "@polka/send-type": "^0.5.2", + "@topcli/cliui": "^1.1.0", + "@topcli/spinner": "^2.1.2", + "cacache": "^18.0.0", + "dotenv": "^16.3.1", + "filenamify": "^6.0.0", + "ini": "^4.1.1", + "kleur": "^4.1.5", + "ms": "^2.1.3", + "open": "^9.1.0", + "polka": "^0.5.2", + "qoa": "^0.2.0", + "sade": "^1.8.1", + "semver": "^7.5.4", + "server-destroy": "^1.0.1", + "sirv": "^2.0.3", + "zup": "0.0.1" + } +} diff --git a/public/js/components/home.js b/public/js/components/home.js index cb4a8b87..30c67047 100644 --- a/public/js/components/home.js +++ b/public/js/components/home.js @@ -24,15 +24,16 @@ export class HomeView { } generateScorecard() { - const { repository } = this.secureDataSet.linker.get(0); - const repoName = utils.getGithubRepositoryPath( - utils.parseRepositoryUrl(repository) - ) + const { name } = this.secureDataSet.linker.get(0); + const pkg = this.secureDataSet.data.dependencies[name]; + const repoName = utils.getRepositoryName(pkg); + const platform = utils.getRepositoryPlatform(pkg); + if (repoName === null) { return; } - fetchScorecardData(repoName).then((data) => { + fetchScorecardData(repoName, platform).then((data) => { if (data !== null) { document .querySelector(".home--header--scorecard .score") @@ -40,7 +41,7 @@ export class HomeView { document.getElementById("home-scorecard-score").innerHTML = data.score; const scorescardElement = document.querySelector(".home--header--scorecard"); scorescardElement.addEventListener("click", () => { - window.open(getScorecardLink(repoName), "_blank"); + window.open(getScorecardLink(repoName, platform), "_blank"); }); scorescardElement.style.display = "flex"; } @@ -209,7 +210,7 @@ export class HomeView { const maxAuthors = 8; const hideItems = authors.length > maxAuthors; - for (let id = 0; id { + const isGitlab = this.package.links.gitlab || utils.isGitLabHost(this.package.links.homepage?.href); + const platform = isGitlab ? "gitlab.com" : "github.com"; + + fetchScorecardData(repoName, platform).then((data) => { if (!data) { return this.hide(); } - pannel.appendChild(this.renderScorecard(data, repoName)); + pannel.appendChild(this.renderScorecard(data, repoName, platform)); document.getElementById('scorecard-menu').style.display = 'flex'; }); } - renderScorecard(data, repoName) { + renderScorecard(data, repoName, platform) { const { score, checks } = data; const container = utils.createDOMElement('div', { @@ -56,7 +52,7 @@ export class Scorecard { document.getElementById('head-score').innerText = score; document .querySelector(".score-header .visualizer a") - .setAttribute('href', getScorecardLink(repoName)); + .setAttribute('href', getScorecardLink(repoName, platform)); container.childNodes.forEach((check, checkKey) => { check.addEventListener('click', () => { diff --git a/public/js/master.js b/public/js/master.js index bf0fcd25..0ad209a5 100644 --- a/public/js/master.js +++ b/public/js/master.js @@ -16,6 +16,7 @@ document.addEventListener("DOMContentLoaded", async () => { window.navigation = new ViewNavigation(); window.wiki = new Wiki(); let currentNodeParams; + let packageInfoOpened = false; const levelNodesParams = new Map(); const secureDataSet = new NodeSecureDataSet({ flagsToIgnore: window.settings.config.ignore.flags, @@ -35,6 +36,11 @@ document.addEventListener("DOMContentLoaded", async () => { }; levelNodesParams.set(0, rootNodeParams); + window.addEventListener("package-info-closed", () => { + currentNodeParams = null; + packageInfoOpened = false; + }) + nsn.network.on("click", updateShowInfoMenu); window.addEventListener("settings-saved", async (event) => { @@ -68,10 +74,11 @@ document.addEventListener("DOMContentLoaded", async () => { return PackageInfo.close(); } - if (currentNodeParams?.nodes[0] === params.nodes[0]) { + if (currentNodeParams?.nodes[0] === params.nodes[0] && packageInfoOpened === true) { return; } + packageInfoOpened = true; currentNodeParams = params; const currentNode = currentNodeParams.nodes[0]; const selectedNode = secureDataSet.linker.get( @@ -83,12 +90,9 @@ document.addEventListener("DOMContentLoaded", async () => { // Defines level of dependency. 0 is the root node, 1 is the first level of dependency, etc. let level = 0; document.addEventListener("keydown", (event) => { - if (currentNodeParams === null) { - currentNodeParams = rootNodeParams; - } - - const nodeDependencyName = secureDataSet.linker.get(currentNodeParams.nodes[0]).name; - const usedBy = [...secureDataSet.linker].filter(([id, opt]) => Object.keys(secureDataSet.linker.get(currentNodeParams.nodes[0]).usedBy).includes(opt.name)); + const nodeParam = currentNodeParams ?? rootNodeParams; + const nodeDependencyName = secureDataSet.linker.get(nodeParam.nodes[0]).name; + const usedBy = [...secureDataSet.linker].filter(([id, opt]) => Object.keys(secureDataSet.linker.get(nodeParam.nodes[0]).usedBy).includes(opt.name)); const use = [...secureDataSet.linker].filter(([id, opt]) => Reflect.has(opt.usedBy, nodeDependencyName)); switch (event.code) { @@ -101,7 +105,7 @@ document.addEventListener("DOMContentLoaded", async () => { const previousNodeDependencyName = secureDataSet.linker.get(levelNodesParams.get(level === 0 ? 0 : level - 1).nodes[0]).name; const useByPrevious = [...secureDataSet.linker].filter(([id, opt]) => Reflect.has(opt.usedBy, previousNodeDependencyName) && - opt.id !== currentNodeParams.nodes[0] && + opt.id !== nodeParam.nodes[0] && opt.id !== levelNodesParams.get(level - 1).nodes[0] ); if (useByPrevious.length <= 1) { @@ -109,14 +113,14 @@ document.addEventListener("DOMContentLoaded", async () => { } useByPrevious.sort(([aId], [bId]) => bId - aId) - const activeNode = (useByPrevious.find(([id]) => id < currentNodeParams.nodes[0]) ?? useByPrevious[0])[0]; + const activeNode = (useByPrevious.find(([id]) => id < nodeParam.nodes[0]) ?? useByPrevious[0])[0]; nsn.focusNodeById(activeNode); currentNodeParams = { nodes: [activeNode], edges: nsn.network.getConnectedEdges(activeNode) }; - levelNodesParams.set(level, currentNodeParams); + levelNodesParams.set(level, nodeParam); } break; case "ArrowRight": @@ -128,7 +132,7 @@ document.addEventListener("DOMContentLoaded", async () => { const previousNodeDependencyName = secureDataSet.linker.get(levelNodesParams.get(level === 0 ? 0 : level - 1).nodes[0]).name; const useByPrevious = [...secureDataSet.linker].filter(([id, opt]) => Reflect.has(opt.usedBy, previousNodeDependencyName) && - opt.id !== currentNodeParams.nodes[0] && + opt.id !== nodeParam.nodes[0] && opt.id !== levelNodesParams.get(level - 1).nodes[0] ); if (useByPrevious.length <= 1) { @@ -136,14 +140,14 @@ document.addEventListener("DOMContentLoaded", async () => { } useByPrevious.sort(([aId], [bId]) => aId - bId); - const activeNode = (useByPrevious.find(([id]) => { console.log(id); return id > currentNodeParams.nodes[0] }) ?? useByPrevious[0])[0]; + const activeNode = (useByPrevious.find(([id]) => { console.log(id); return id > nodeParam.nodes[0] }) ?? useByPrevious[0])[0]; nsn.focusNodeById(activeNode); currentNodeParams = { nodes: [activeNode], edges: nsn.network.getConnectedEdges(activeNode) }; - levelNodesParams.set(level, currentNodeParams); + levelNodesParams.set(level, nodeParam); } break; case "ArrowUp": @@ -159,7 +163,7 @@ document.addEventListener("DOMContentLoaded", async () => { }; nsn.focusNodeById(activeNode); level++; - levelNodesParams.set(level, currentNodeParams); + levelNodesParams.set(level, nodeParam); } const nextLevelNodeMatchingUseDependencies = use.find(([id]) => id === levelNodesParams.get(level + 1)?.nodes[0]); @@ -184,7 +188,7 @@ document.addEventListener("DOMContentLoaded", async () => { }; nsn.focusNodeById(activeNode); level--; - levelNodesParams.set(level, currentNodeParams); + levelNodesParams.set(level, nodeParam); } const previousLevelNodeMatchingUsedByDependencies = usedBy.find(([id]) => id === levelNodesParams.get(level - 1)?.nodes[0]); diff --git a/public/js/scorecard.js b/public/js/scorecard.js index 6ebf0dcf..5493c4e6 100644 --- a/public/js/scorecard.js +++ b/public/js/scorecard.js @@ -4,9 +4,9 @@ import { getJSON } from "@nodesecure/vis-network"; /** * @param {!string} repoName */ -export async function fetchScorecardData(repoName) { +export async function fetchScorecardData(repoName, platform = "github.com") { try { - const { data } = (await getJSON(`/scorecard/${repoName}`)); + const { data } = (await getJSON(`/scorecard/${repoName}?platform=${platform}`)); if (!data) { return null; } @@ -39,7 +39,8 @@ export function getScoreColor(score) { } export function getScorecardLink( - repoName + repoName, + platform ) { - return `https://kooltheba.github.io/openssf-scorecard-api-visualizer/#/projects/github.com/` + repoName; + return `https://kooltheba.github.io/openssf-scorecard-api-visualizer/#/projects/${platform}/${repoName}`; } diff --git a/public/js/utils.js b/public/js/utils.js index bb2da20f..933720d9 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -3,20 +3,66 @@ import avatarURL from "../img/avatar-default.png"; window.activeLegendElement = null; -export function getGithubRepositoryPath(url) { +function getVCSRepositoryPath(url) { + if (!url) { + return null; + } + try { - const github = new URL(url); + const repo = new URL(url); - return github.pathname.slice( + return repo.pathname.slice( 1, - github.pathname.includes(".git") ? -4 : github.pathname.length + repo.pathname.includes(".git") ? -4 : repo.pathname.length ); + } catch { + return null; } - catch { +} + +function getVCSRepositoryPlatform(url) { + if (!url) { + return null; + } + + try { + const repo = new URL(url); + + return repo.host; + } catch { return null; } } +export function getRepositoryName(repository) { + return getVCSRepositoryPath(repository.links?.github?.href) ?? + getVCSRepositoryPath(repository.links?.gitlab?.href) ?? + getVCSRepositoryPath(repository.links?.homepage?.href) ?? + getVCSRepositoryPath(repository.metadata?.homepage) ?? + repository.name; +} + +export function getRepositoryPlatform(repository) { + return getVCSRepositoryPlatform(repository.links?.github?.href) ?? + getVCSRepositoryPlatform(repository.links?.gitlab?.href) ?? + getVCSRepositoryPlatform(repository.links?.homepage) ?? + getVCSRepositoryPlatform(repository.metadata?.homepage) ?? + "github.com"; +} + +export function isGitLabHost(host) { + if (!host) { + return false; + } + + try { + return new URL(host).host === "gitlab.com"; + } + catch { + return false; + } +} + /** * @param {keyof HTMLElementTagNameMap} kind * @param {object} [options] diff --git a/src/commands/scorecard.js b/src/commands/scorecard.js index 5f276a9f..daa2f245 100644 --- a/src/commands/scorecard.js +++ b/src/commands/scorecard.js @@ -14,7 +14,7 @@ function separatorLine() { return grey("-".repeat(80)); } -export function getCurrentRepository() { +export function getCurrentRepository(vcs = "github") { const config = ini.parse(fs.readFileSync(".git/config", "utf-8")); const originMetadata = config["remote \"origin\""]; @@ -22,20 +22,26 @@ export function getCurrentRepository() { return Err("Cannot find origin remote."); } - const [, rawPkg] = originMetadata.url.match(/github\.com(.+)\.git/) ?? []; + const [, rawPkg] = originMetadata.url.match(/(?:github|gitlab)\.com(.+)\.git/) ?? []; + if (!rawPkg) { - return Err("OSSF Scorecard supports projects hosted on Github only."); + return Err("Cannot find version control host."); } - return Ok(rawPkg.slice(1)); + // vcs is github by default. + return Ok([rawPkg.slice(1), originMetadata.url.includes("gitlab") ? "gitlab" : vcs]); } -export async function main(repo) { - const result = typeof repo === "string" ? Ok(repo) : getCurrentRepository(); +export async function main(repo, opts) { + const vcs = opts.vcs.toLowerCase(); + const result = typeof repo === "string" ? Ok([repo, vcs]) : getCurrentRepository(vcs); let repository; + let platform; try { - repository = result.unwrap(); + const [repo, vcs] = result.unwrap(); + repository = repo; + platform = vcs.slice(-4) === ".com" ? vsc : `${vcs}.com`; } catch (error) { console.log(white().bold(result.err)); @@ -45,7 +51,10 @@ export async function main(repo) { let data; try { - data = await scorecard.result(repository); + data = await scorecard.result(repository, { + resolveOnVersionControl: Boolean(process.env.GITHUB_TOKEN || opts.resolveOnVersionControl), + platform + }); } catch (error) { console.log( diff --git a/src/http-server/endpoints/ossf-scorecard.js b/src/http-server/endpoints/ossf-scorecard.js index e1779a17..b7b38683 100644 --- a/src/http-server/endpoints/ossf-scorecard.js +++ b/src/http-server/endpoints/ossf-scorecard.js @@ -4,15 +4,19 @@ import send from "@polka/send-type"; export async function get(req, res) { const { org, pkgName } = req.params; + const { platform = "github.com" } = req.query; try { - const data = await scorecard.result(`${org}/${pkgName}`); + const data = await scorecard.result(`${org}/${pkgName}`, { + resolveOnVersionControl: Boolean(process.env.GITHUB_TOKEN), + resolveOnNpmRegistry: false, + platform + }); return send(res, 200, { data }); } - catch (error) { return send( res, diff --git a/test/commands/scorecard.test.js b/test/commands/scorecard.test.js index c162c3a1..bcaa07bf 100644 --- a/test/commands/scorecard.test.js +++ b/test/commands/scorecard.test.js @@ -74,7 +74,7 @@ test("scorecard should display fastify scorecard", async() => { }); test("should not display scorecard for unknown repository", async() => { - const packageName = "unkown/repository"; + const packageName = "fastify/fastify"; const scorecardCliOptions = { path: kProcessPath, args: [packageName], @@ -109,7 +109,7 @@ test("should retrieve repository whithin git config", async() => { } }); - assert.deepEqual(testingModule.getCurrentRepository(), Ok("myawesome/repository")); + assert.deepEqual(testingModule.getCurrentRepository(), Ok(["myawesome/repository", "github"])); }); test("should not find origin remote", async() => { @@ -123,23 +123,3 @@ test("should not find origin remote", async() => { assert.equal(result.err, true); assert.equal(result.val, "Cannot find origin remote."); }); - -test("should support github only", async() => { - const testingModule = await esmock("../../src/commands/scorecard.js", { - fs: { - readFileSync: () => [ - "[remote \"origin\"]", - "\turl = git@gitlab.com:myawesome/repository.git" - ].join("\n") - } - }); - // NOTE: we can then test that the only expected one console.log is correct - // it's a bit simpler that running the process to parse stdout - const logs = []; - console.log = (str) => logs.push(str); - - await testingModule.main(); - - assert.equal(logs.length, 1); - assert.equal(stripAnsi(logs[0]), "OSSF Scorecard supports projects hosted on Github only."); -}); diff --git a/test/httpServer.test.js b/test/httpServer.test.js index d0c296bb..33d6f071 100644 --- a/test/httpServer.test.js +++ b/test/httpServer.test.js @@ -289,6 +289,13 @@ describe("httpServer", () => { assert.equal(result.data.data.repo.name, "github.com/NodeSecure/cli"); }); + test("'/scorecard/:org/:pkgName' should return scorecard data for GitLab repo", async() => { + const result = await get(new URL("/scorecard/gitlab-org/gitlab-ui?platform=gitlab.com", HTTP_URL)); + + assert.equal(result.statusCode, 200); + assert.equal(result.data.data.repo.name, "gitlab.com/gitlab-org/gitlab-ui"); + }); + test("'/scorecard/:org/:pkgName' should not find repo", async() => { const wrongPackageName = "br-br-br-brah"; diff --git a/test/process/scorecard.js b/test/process/scorecard.js index 61fe22af..730cb9f3 100644 --- a/test/process/scorecard.js +++ b/test/process/scorecard.js @@ -2,4 +2,4 @@ import * as scorecard from "../../src/commands/scorecard.js"; import { prepareProcess } from "../helpers/cliCommandRunner.js"; -prepareProcess(scorecard.main); +prepareProcess(scorecard.main, ["fastify/fastify", { resolveOnVersionControl: true, vcs: "github" }]); diff --git a/workspaces/vis-network/src/utils.js b/workspaces/vis-network/src/utils.js index 87444295..4d5a6da2 100644 --- a/workspaces/vis-network/src/utils.js +++ b/workspaces/vis-network/src/utils.js @@ -17,6 +17,15 @@ export async function getJSON(path, customHeaders = Object.create(null)) { headers: Object.assign({}, headers, customHeaders) }); + if (raw.ok === false) { + const { status, statusText } = raw; + + return { + status, + statusText + }; + } + return raw.json(); }