From 61624214d0c0c5f3ac004609dc443b6ee2383fe1 Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Fri, 14 Mar 2025 12:50:06 +0100 Subject: [PATCH 1/2] [scan report] Support markdown, cleanup, add tests --- src/commands/scan/cmd-scan-report.ts | 2 +- src/commands/scan/generate-report.test.ts | 421 +++++++++++++++++---- src/commands/scan/generate-report.ts | 134 +++++-- src/commands/scan/report-full-scan.test.ts | 192 ++++++++++ src/commands/scan/report-full-scan.ts | 115 +++++- src/utils/walk-nested-map.ts | 14 + 6 files changed, 768 insertions(+), 110 deletions(-) create mode 100644 src/commands/scan/report-full-scan.test.ts create mode 100644 src/utils/walk-nested-map.ts diff --git a/src/commands/scan/cmd-scan-report.ts b/src/commands/scan/cmd-scan-report.ts index ea4d9d319..d5b2b161f 100644 --- a/src/commands/scan/cmd-scan-report.ts +++ b/src/commands/scan/cmd-scan-report.ts @@ -134,7 +134,7 @@ async function run( orgSlug, fullScanId, includeLicensePolicy: false, // !!license, - includeSecurityPolicy: !!security, + includeSecurityPolicy: typeof security === 'boolean' ? security : true, outputKind: json ? 'json' : markdown ? 'markdown' : 'text', filePath: file, fold: fold as 'none' | 'file' | 'pkg' | 'version', diff --git a/src/commands/scan/generate-report.test.ts b/src/commands/scan/generate-report.test.ts index d75413349..48e12d308 100644 --- a/src/commands/scan/generate-report.test.ts +++ b/src/commands/scan/generate-report.test.ts @@ -8,6 +8,8 @@ import type { components } from '@socketsecurity/sdk/types/api' describe('generate-report', () => { it('should accept empty args', () => { const result = generateReport([], undefined, undefined, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' }) @@ -16,6 +18,12 @@ describe('generate-report', () => { { "alerts": Map {}, "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) }) @@ -38,17 +46,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -69,6 +85,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } @@ -81,14 +99,34 @@ describe('generate-report', () => { "tslib" => Map { "1.14.1" => Map { "package/which.js" => Map { - "envVars at 54:72" => "error", - "envVars at 200:250" => "error", + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, }, }, "healthy": false, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) expect(result.healthy).toBe(false) @@ -111,6 +149,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } @@ -123,14 +163,34 @@ describe('generate-report', () => { "tslib" => Map { "1.14.1" => Map { "package/which.js" => Map { - "envVars at 54:72" => "warn", - "envVars at 200:250" => "warn", + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "warn", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "warn", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, }, }, "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) expect(result.healthy).toBe(true) @@ -153,17 +213,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -184,17 +252,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -215,17 +291,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -244,17 +328,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -271,17 +363,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -304,17 +404,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'ignore' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -335,6 +443,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'ignore' } @@ -347,14 +457,34 @@ describe('generate-report', () => { "tslib" => Map { "1.14.1" => Map { "package/which.js" => Map { - "envVars at 54:72" => "error", - "envVars at 200:250" => "error", + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, }, }, "healthy": false, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) expect(result.healthy).toBe(false) @@ -377,6 +507,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'ignore' } @@ -389,14 +521,34 @@ describe('generate-report', () => { "tslib" => Map { "1.14.1" => Map { "package/which.js" => Map { - "envVars at 54:72" => "warn", - "envVars at 200:250" => "warn", + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "warn", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "warn", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, }, }, "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) expect(result.healthy).toBe(true) @@ -419,6 +571,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'ignore' } @@ -431,14 +585,34 @@ describe('generate-report', () => { "tslib" => Map { "1.14.1" => Map { "package/which.js" => Map { - "envVars at 54:72" => "monitor", - "envVars at 200:250" => "monitor", + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "monitor", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "monitor", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, }, }, "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) expect(result.healthy).toBe(true) @@ -461,6 +635,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'ignore' } @@ -473,14 +649,34 @@ describe('generate-report', () => { "tslib" => Map { "1.14.1" => Map { "package/which.js" => Map { - "envVars at 54:72" => "ignore", - "envVars at 200:250" => "ignore", + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "ignore", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "ignore", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, }, }, "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) expect(result.healthy).toBe(true) @@ -503,17 +699,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'ignore' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -532,17 +736,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'ignore' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -559,17 +771,25 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'ignore' } ) expect(result).toMatchInlineSnapshot(` - { - "alerts": Map {}, - "healthy": true, - } - `) + { + "alerts": Map {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "ignore", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", + } + `) expect(result.healthy).toBe(true) expect(result.alerts.size).toBe(0) }) @@ -593,6 +813,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'none', reportLevel: 'warn' } @@ -605,14 +827,34 @@ describe('generate-report', () => { "tslib" => Map { "1.14.1" => Map { "package/which.js" => Map { - "envVars at 54:72" => "error", - "envVars at 200:250" => "error", + "envVars at 54:72" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, + "envVars at 200:250" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, }, }, "healthy": false, + "options": { + "fold": "none", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) }) @@ -633,6 +875,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'file', reportLevel: 'warn' } @@ -644,12 +888,25 @@ describe('generate-report', () => { "npm" => Map { "tslib" => Map { "1.14.1" => Map { - "package/which.js" => "error", + "package/which.js" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, }, "healthy": false, + "options": { + "fold": "file", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) }) @@ -670,6 +927,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'version', reportLevel: 'warn' } @@ -680,11 +939,24 @@ describe('generate-report', () => { "alerts": Map { "npm" => Map { "tslib" => Map { - "1.14.1" => "error", + "1.14.1" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, }, "healthy": false, + "options": { + "fold": "version", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) }) @@ -705,6 +977,8 @@ describe('generate-report', () => { } } as SocketSdkReturnType<'getOrgSecurityPolicy'>, { + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee', fold: 'pkg', reportLevel: 'warn' } @@ -714,10 +988,23 @@ describe('generate-report', () => { { "alerts": Map { "npm" => Map { - "tslib" => "error", + "tslib" => { + "manifest": [ + "package-lock.json", + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1", + }, }, }, "healthy": false, + "options": { + "fold": "pkg", + "reportLevel": "warn", + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee", } `) }) diff --git a/src/commands/scan/generate-report.ts b/src/commands/scan/generate-report.ts index 32ef0cf65..fdff6ad68 100644 --- a/src/commands/scan/generate-report.ts +++ b/src/commands/scan/generate-report.ts @@ -1,15 +1,31 @@ -import { SocketSdkReturnType } from '@socketsecurity/sdk' -import { components } from '@socketsecurity/sdk/types/api' - import constants from '../../constants' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' +import type { components } from '@socketsecurity/sdk/types/api' + type AlertAction = 'defer' | 'ignore' | 'monitor' | 'error' | 'warn' type AlertKey = string -type FileMap = Map> -type VersionMap = Map -type PackageMap = Map -type ViolationsMap = Map +type FileMap = Map> +type VersionMap = Map +type PackageMap = Map +type EcoMap = Map +export type ViolationsMap = Map + +export interface ScanReport { + orgSlug: string + scanId: string + options: { fold: string; reportLevel: string } + healthy: boolean + alerts: ViolationsMap +} + +export type ReportLeafNode = { + type: string + policy: 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' + url: string + manifest: string[] +} export function generateReport( scan: Array, @@ -17,12 +33,16 @@ export function generateReport( securityPolicy: undefined | SocketSdkReturnType<'getOrgSecurityPolicy'>, { fold, - reportLevel + orgSlug, + reportLevel, + scanId }: { + orgSlug: string + scanId: string fold: 'pkg' | 'version' | 'file' | 'none' reportLevel: 'defer' | 'ignore' | 'monitor' | 'warn' | 'error' } -) { +): ScanReport { const now = Date.now() // Lazily access constants.spinner. @@ -56,13 +76,14 @@ export function generateReport( securityPolicy?.data.securityPolicyRules if (securityPolicy && securityRules) { // Note: reportLevel: error > warn > monitor > ignore > defer - scan.forEach(art => { + scan.forEach(artifact => { const { alerts, name: pkgName = '', type: ecosystem, version = '' - } = art + } = artifact + alerts?.forEach( ( alert: NonNullable< @@ -75,6 +96,7 @@ export function generateReport( case 'error': { healthy = false addAlert( + artifact, violations, fold, ecosystem, @@ -88,6 +110,7 @@ export function generateReport( case 'warn': { if (reportLevel !== 'error') { addAlert( + artifact, violations, fold, ecosystem, @@ -102,6 +125,7 @@ export function generateReport( case 'monitor': { if (reportLevel !== 'warn' && reportLevel !== 'error') { addAlert( + artifact, violations, fold, ecosystem, @@ -121,6 +145,7 @@ export function generateReport( reportLevel !== 'monitor' ) { addAlert( + artifact, violations, fold, ecosystem, @@ -137,6 +162,7 @@ export function generateReport( // Not sure but ignore for now. Defer to later ;) if (reportLevel === 'defer') { addAlert( + artifact, violations, fold, ecosystem, @@ -162,13 +188,31 @@ export function generateReport( const report = { healthy, + orgSlug, + scanId, + options: { fold, reportLevel }, alerts: violations } return report } +function createLeaf( + art: components['schemas']['SocketArtifact'], + alert: NonNullable[number], + policyAction: AlertAction +): ReportLeafNode { + const leaf: ReportLeafNode = { + type: alert.type, + policy: policyAction, + url: `https://socket.dev/${art.type}/package/${art.name}/${art.version}`, + manifest: art.manifestFiles?.map(obj => obj.file) ?? [] + } + return leaf +} + function addAlert( + art: components['schemas']['SocketArtifact'], violations: ViolationsMap, foldSetting: 'pkg' | 'version' | 'file' | 'none', ecosystem: string, @@ -176,38 +220,60 @@ function addAlert( version: string, alert: NonNullable[number], policyAction: AlertAction -) { - if (!violations.has(ecosystem)) { - violations.set(ecosystem, new Map()) - } - const ecomap = violations.get(ecosystem)! - if (!ecomap.has(pkgName)) ecomap.set(pkgName, new Map()) - +): void { + if (!violations.has(ecosystem)) violations.set(ecosystem, new Map()) + const ecomap: EcoMap = violations.get(ecosystem)! if (foldSetting === 'pkg') { - if (policyAction === 'error') ecomap.set(pkgName, 'error') - else if (!ecomap.get(pkgName)) ecomap.set(pkgName, 'warn') + const existing = ecomap.get(pkgName) as ReportLeafNode | undefined + if (!existing || isStricterPolicy(existing.policy, policyAction)) { + ecomap.set(pkgName, createLeaf(art, alert, policyAction)) + } } else { - const pkgmap = ecomap.get(pkgName) as VersionMap - if (!pkgmap.has(version)) pkgmap.set(version, new Map()) - + if (!ecomap.has(pkgName)) ecomap.set(pkgName, new Map()) + const pkgmap = ecomap.get(pkgName) as PackageMap if (foldSetting === 'version') { - if (policyAction === 'error') pkgmap.set(version, 'error') - else if (!pkgmap.get(version)) pkgmap.set(version, 'warn') + const existing = pkgmap.get(version) as ReportLeafNode | undefined + if (!existing || isStricterPolicy(existing.policy, policyAction)) { + pkgmap.set(version, createLeaf(art, alert, policyAction)) + } } else { + if (!pkgmap.has(version)) pkgmap.set(version, new Map()) const file = alert.file || '' - const vermap = pkgmap.get(version) as FileMap - if (!vermap.has(file)) vermap.set(file, new Map()) + const vermap = pkgmap.get(version) as VersionMap if (foldSetting === 'file') { - if (policyAction === 'error') vermap.set(file, 'error') - else if (!vermap.get(file)) vermap.set(file, 'warn') + const existing = vermap.get(file) as ReportLeafNode | undefined + if (!existing || isStricterPolicy(existing.policy, policyAction)) { + vermap.set(file, createLeaf(art, alert, policyAction)) + } } else { - const filemap = vermap.get(file) as Map - filemap.set( - `${alert.type} at ${alert.start}:${alert.end}`, - policyAction - ) + if (!vermap.has(file)) vermap.set(file, new Map()) + const key = `${alert.type} at ${alert.start}:${alert.end}` + const filemap: FileMap = vermap.get(file) as FileMap + const existing = filemap.get(key) as ReportLeafNode | undefined + if (!existing || isStricterPolicy(existing.policy, policyAction)) { + filemap.set(key, createLeaf(art, alert, policyAction)) + } } } } } + +function isStricterPolicy( + was: 'error' | 'warn' | 'monitor' | 'ignore' | 'defer', + is: 'error' | 'warn' | 'monitor' | 'ignore' | 'defer' +): boolean { + // error > warn > monitor > ignore > defer > {unknown} + if (was === 'error') return false + if (is === 'error') return true + if (was === 'warn') return false + if (is === 'warn') return false + if (was === 'monitor') return false + if (is === 'monitor') return false + if (was === 'ignore') return false + if (is === 'ignore') return false + if (was === 'defer') return false + if (is === 'defer') return false + // unreachable? + return false +} diff --git a/src/commands/scan/report-full-scan.test.ts b/src/commands/scan/report-full-scan.test.ts new file mode 100644 index 000000000..d3dc2d247 --- /dev/null +++ b/src/commands/scan/report-full-scan.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest' + +import { toJsonReport, toMarkdownReport } from './report-full-scan' + +import type { ScanReport } from './generate-report' + +describe('report-full-scan', () => { + describe('toJsonReport', () => { + it('should be able to generate a healthy json report', () => { + expect(toJsonReport(getHealthyReport())).toMatchInlineSnapshot(` + "{ + "alerts": {}, + "healthy": true, + "options": { + "fold": "none", + "reportLevel": "warn" + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee" + }" + `) + }) + + it('should be able to generate an unhealthy json report', () => { + expect(toJsonReport(getUnhealthyReport())).toMatchInlineSnapshot(` + "{ + "alerts": { + "npm": { + "tslib": { + "1.14.1": { + "package/which.js": { + "envVars at 54:72": { + "manifest": [ + "package-lock.json" + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1" + }, + "envVars at 200:250": { + "manifest": [ + "package-lock.json" + ], + "policy": "error", + "type": "envVars", + "url": "https://socket.dev/npm/package/tslib/1.14.1" + } + } + } + } + } + }, + "healthy": false, + "options": { + "fold": "none", + "reportLevel": "warn" + }, + "orgSlug": "fakeorg", + "scanId": "scan-ai-dee" + }" + `) + }) + }) + + describe('toJsonReport', () => { + it('should be able to generate a healthy md report', () => { + expect(toMarkdownReport(getHealthyReport())).toMatchInlineSnapshot(` + "# Scan Policy Report + + This report tells you whether the results of a Socket scan results violate the + security or license policy set by your organization. + + ## Health status + + The scan *PASSES* all requirements set by your security and license policy. + + ## Settings + + Configuration used to generate this report: + + - Organization: fakeorg + - Scan ID: scan-ai-dee + - Alert folding: none + - Minimal policy level for alert to be included in report: warn + + ## Alerts + + The scan contained no alerts for with a policy set to at least "warn". + " + `) + }) + + it('should be able to generate an unhealthy md report', () => { + expect(toMarkdownReport(getUnhealthyReport())).toMatchInlineSnapshot(` + "# Scan Policy Report + + This report tells you whether the results of a Socket scan results violate the + security or license policy set by your organization. + + ## Health status + + The scan *VIOLATES* one or more policies set to the "error" level. + + ## Settings + + Configuration used to generate this report: + + - Organization: fakeorg + - Scan ID: scan-ai-dee + - Alert folding: none + - Minimal policy level for alert to be included in report: warn + + ## Alerts + + All the alerts from the scan with a policy set to at least "warn"}. + + | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | + | Policy | Alert Type | Package | Introduced by | url | Manifest file | + | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | + | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | + | error | envVars | tslib | 1.14.1 | https://socket.dev/npm/package/tslib/1.14.1 | package-lock.json | + | ------ | ---------- | ------- | ------------- | ------------------------------------------- | ----------------- | + " + `) + }) + }) +}) + +function getHealthyReport(): ScanReport { + return { + alerts: new Map(), + healthy: true, + options: { + fold: 'none', + reportLevel: 'warn' + }, + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee' + } +} + +function getUnhealthyReport(): ScanReport { + return { + alerts: new Map([ + [ + 'npm', + new Map([ + [ + 'tslib', + new Map([ + [ + '1.14.1', + new Map([ + [ + 'package/which.js', + new Map([ + [ + 'envVars at 54:72', + { + manifest: ['package-lock.json'], + policy: 'error' as const, + type: 'envVars', + url: 'https://socket.dev/npm/package/tslib/1.14.1' + } + ], + [ + 'envVars at 200:250', + { + manifest: ['package-lock.json'], + policy: 'error' as const, + type: 'envVars', + url: 'https://socket.dev/npm/package/tslib/1.14.1' + } + ] + ]) + ] + ]) + ] + ]) + ] + ]) + ] + ]), + healthy: false, + options: { + fold: 'none', + reportLevel: 'warn' + }, + orgSlug: 'fakeorg', + scanId: 'scan-ai-dee' + } +} diff --git a/src/commands/scan/report-full-scan.ts b/src/commands/scan/report-full-scan.ts index 23c579d28..1d96fa955 100644 --- a/src/commands/scan/report-full-scan.ts +++ b/src/commands/scan/report-full-scan.ts @@ -5,6 +5,10 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { fetchReportData } from './fetch-report-data' import { generateReport } from './generate-report' import { mapToObject } from '../../utils/map-to-object' +import { mdTable } from '../../utils/markdown' +import { walkNestedMap } from '../../utils/walk-nested-map' + +import type { ReportLeafNode, ScanReport } from './generate-report' export async function reportFullScan({ filePath, @@ -47,30 +51,34 @@ export async function reportFullScan({ } = await fetchReportData( orgSlug, fullScanId, - includeLicensePolicy - // includeSecurityPolicy + // includeLicensePolicy + includeSecurityPolicy ) if (!ok) { return } - const report = generateReport( + const scanReport = generateReport( scan, undefined, // licensePolicy, securityPolicy, { + orgSlug, + scanId: fullScanId, fold, reportLevel } ) - if (outputKind === 'json') { - const obj = mapToObject(report.alerts) - - const json = JSON.stringify(obj, null, 2) + if ( + outputKind === 'json' || + (outputKind === 'text' && filePath && filePath.endsWith('.json')) + ) { + const json = toJsonReport(scanReport) if (filePath && filePath !== '-') { + logger.log('Writing json report to', filePath) return await fs.writeFile(filePath, json) } @@ -78,5 +86,96 @@ export async function reportFullScan({ return } - logger.dir(report, { depth: null }) + if (outputKind === 'markdown' || (filePath && filePath.endsWith('.md'))) { + const md = toMarkdownReport(scanReport) + + if (filePath && filePath !== '-') { + logger.log('Writing markdown report to', filePath) + return await fs.writeFile(filePath, md) + } + + logger.log(md) + return + } + + logger.dir(scanReport, { depth: null }) +} + +export function toJsonReport(report: ScanReport): string { + const obj = mapToObject(report.alerts) + + const json = JSON.stringify( + { + ...report, + alerts: obj + }, + null, + 2 + ) + + return json +} + +export function toMarkdownReport(report: ScanReport): string { + const flatData = Array.from(walkNestedMap(report.alerts)).map( + ({ keys, value }: { keys: string[]; value: ReportLeafNode }) => { + const { manifest, policy, type, url } = value + return { + 'Alert Type': type, + Package: keys[1] || '', + 'Introduced by': keys[2] || '', + url, + 'Manifest file': manifest.join(', '), + Policy: policy + } + } + ) + + const md = + ` +# Scan Policy Report + +This report tells you whether the results of a Socket scan results violate the +security or license policy set by your organization. + +## Health status + +${ + report.healthy + ? 'The scan *PASSES* all requirements set by your security and license policy.' + : 'The scan *VIOLATES* one or more policies set to the "error" level.' +} + +## Settings + +Configuration used to generate this report: + +- Organization: ${report.orgSlug} +- Scan ID: ${report.scanId} +- Alert folding: ${report.options.fold === 'none' ? 'none' : `up to ${report.options.fold}`} +- Minimal policy level for alert to be included in report: ${report.options.reportLevel === 'defer' ? 'everything' : report.options.reportLevel} + +## Alerts + +${ + report.alerts.size + ? `All the alerts from the scan with a policy set to at least "${report.options.reportLevel}"}.` + : `The scan contained no alerts for with a policy set to at least "${report.options.reportLevel}".` +} + +${ + !report.alerts.size + ? '' + : mdTable(flatData, [ + 'Policy', + 'Alert Type', + 'Package', + 'Introduced by', + 'url', + 'Manifest file' + ]) +} + `.trim() + '\n' + + return md } diff --git a/src/utils/walk-nested-map.ts b/src/utils/walk-nested-map.ts new file mode 100644 index 000000000..7827c35ee --- /dev/null +++ b/src/utils/walk-nested-map.ts @@ -0,0 +1,14 @@ +type NestedMap = Map> + +export function* walkNestedMap( + map: NestedMap, + keys: string[] = [] +): Generator<{ keys: string[]; value: T }> { + for (const [_key, value] of map.entries()) { + if (value instanceof Map) { + yield* walkNestedMap(value as NestedMap, keys.concat(_key)) + } else { + yield { keys, value: value } + } + } +} From 955cd01507907882715893c30119af7514f4b093 Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Fri, 14 Mar 2025 13:17:49 +0100 Subject: [PATCH 2/2] Add test for the map walker --- src/utils/walk-nested-map.test.ts | 182 ++++++++++++++++++++++++++++++ src/utils/walk-nested-map.ts | 6 +- 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 src/utils/walk-nested-map.test.ts diff --git a/src/utils/walk-nested-map.test.ts b/src/utils/walk-nested-map.test.ts new file mode 100644 index 000000000..5c79ca340 --- /dev/null +++ b/src/utils/walk-nested-map.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' + +import { walkNestedMap } from './walk-nested-map' + +describe('walkNestedMap', () => { + it('should walk a flat map', () => { + expect( + Array.from( + walkNestedMap( + new Map([ + ['x', 1], + ['y', 2], + ['z', 3] + ]) + ) + ) + ).toMatchInlineSnapshot(` + [ + { + "keys": [ + "x", + ], + "value": 1, + }, + { + "keys": [ + "y", + ], + "value": 2, + }, + { + "keys": [ + "z", + ], + "value": 3, + }, + ] + `) + }) + + it('should walk a 2d map', () => { + expect( + Array.from( + walkNestedMap( + new Map([ + [ + 'x', + new Map([ + ['x2', 1], + ['y2', 2], + ['z2', 3] + ]) + ], + [ + 'y', + new Map([ + ['x3', 1], + ['y3', 2], + ['z3', 3] + ]) + ] + ]) + ) + ) + ).toMatchInlineSnapshot(` + [ + { + "keys": [ + "x", + "x2", + ], + "value": 1, + }, + { + "keys": [ + "x", + "y2", + ], + "value": 2, + }, + { + "keys": [ + "x", + "z2", + ], + "value": 3, + }, + { + "keys": [ + "y", + "x3", + ], + "value": 1, + }, + { + "keys": [ + "y", + "y3", + ], + "value": 2, + }, + { + "keys": [ + "y", + "z3", + ], + "value": 3, + }, + ] + `) + }) + + it('should walk a 3d map', () => { + expect( + Array.from( + walkNestedMap( + new Map([ + [ + 'a', + new Map([ + [ + 'x', + new Map([ + ['x2', 1], + ['y2', 2], + ['z2', 3] + ]) + ], + [ + 'y', + new Map([ + ['x3', 1], + ['y3', 2], + ['z3', 3] + ]) + ] + ]) + ], + [ + 'b', + new Map([ + [ + 'x', + new Map([ + ['x2', 1], + ['y2', 2], + ['z2', 3] + ]) + ], + [ + 'y', + new Map([ + ['x3', 1], + ['y3', 2], + ['z3', 3] + ]) + ] + ]) + ] + ]) + ) + ) + // Makes test easier to read... + .map(obj => JSON.stringify(obj)) + ).toMatchInlineSnapshot(` + [ + "{"keys":["a","x","x2"],"value":1}", + "{"keys":["a","x","y2"],"value":2}", + "{"keys":["a","x","z2"],"value":3}", + "{"keys":["a","y","x3"],"value":1}", + "{"keys":["a","y","y3"],"value":2}", + "{"keys":["a","y","z3"],"value":3}", + "{"keys":["b","x","x2"],"value":1}", + "{"keys":["b","x","y2"],"value":2}", + "{"keys":["b","x","z2"],"value":3}", + "{"keys":["b","y","x3"],"value":1}", + "{"keys":["b","y","y3"],"value":2}", + "{"keys":["b","y","z3"],"value":3}", + ] + `) + }) +}) diff --git a/src/utils/walk-nested-map.ts b/src/utils/walk-nested-map.ts index 7827c35ee..ddaea87b5 100644 --- a/src/utils/walk-nested-map.ts +++ b/src/utils/walk-nested-map.ts @@ -4,11 +4,11 @@ export function* walkNestedMap( map: NestedMap, keys: string[] = [] ): Generator<{ keys: string[]; value: T }> { - for (const [_key, value] of map.entries()) { + for (const [key, value] of map.entries()) { if (value instanceof Map) { - yield* walkNestedMap(value as NestedMap, keys.concat(_key)) + yield* walkNestedMap(value as NestedMap, keys.concat(key)) } else { - yield { keys, value: value } + yield { keys: keys.concat(key), value: value } } } }