From a2b8e3f636dc61a981c0bb42aa271574e4f61c12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 12:27:09 +0000 Subject: [PATCH 1/2] fix: redact home-directory paths from machine-readable outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan JSON, SARIF, Markdown, HTML, and JUnit now write paths relative to the scan root, or ~/… when a path sits under a home directory outside that root. vg build --json and vg doctor --json follow the same rule. Terminal text is unchanged. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- DOCS.md | 8 +- src/commands/build.ts | 5 +- src/commands/doctor.ts | 3 +- src/core-open/formatters/markdown.ts | 4 +- src/core-open/formatters/sarif.ts | 6 +- src/core-open/run-core-scan.ts | 24 ++-- src/core-open/utils/shareable-path.ts | 133 +++++++++++++++++++ src/reporting/commands/report.ts | 12 +- src/reporting/commands/scan.ts | 5 +- src/reporting/formatters/html.ts | 7 +- src/reporting/formatters/markdown.ts | 4 +- src/reporting/junit-report.ts | 39 ++++-- test/shareable-paths.test.ts | 180 ++++++++++++++++++++++++++ 13 files changed, 398 insertions(+), 32 deletions(-) create mode 100644 src/core-open/utils/shareable-path.ts create mode 100644 test/shareable-paths.test.ts diff --git a/DOCS.md b/DOCS.md index 7f9fb200..0bbc5d4b 100644 --- a/DOCS.md +++ b/DOCS.md @@ -4571,7 +4571,7 @@ vg doctor --local | Flag | Description | |------|-------------| -| `--json` | Machine-readable JSON on stdout | +| `--json` | Machine-readable JSON on stdout. The scanned directory is `.`. Other paths follow the [JSON artifact](#json-artifact) relative and `~/` rules. | | `--offline` | Skip the hosted reachability probe | | `-C, --cwd ` | Run as if started in that directory | @@ -4770,12 +4770,16 @@ The default output. A coloured, human-readable report showing: The full scan artifact in JSON format. Contains all raw data, scores, findings, and VCS metadata. Stable schema (`schemaVersion: "1.0"`). This is the same artifact saved to `.vibgrate/scan_result.json`. +Paths in this artifact are relative to the scanned directory. A path inside that directory has no leading slash (`package.json`, or `.` for the directory itself). A path outside it whose absolute form is under `/home/`, `/Users/`, or `:/Users/` is written as `~/…`. The same tree produces the same relative strings on every machine. `vg build --json` and `vg doctor --json` use the same rule for paths they emit (`artifacts`, `cas.dir`, `root`, and any other absolute home path in that document). Terminal text is left as recorded, so a local debugging session still shows a path you can open. + Dependency identity in this file is `projects[].type` plus `dependencies[].package` and `resolvedVersion`. An advisory match is `extended.vulnerabilities.packages[]` (`ecosystem`, `package`, `version`). `vg sbom export` writes those coordinates as a package URL. The file has no CPE field. See [Component identity](#component-identity). A dependency row has no package digest. `projects[].projectId` and `solutions[].solutionId` identify the project and the solution. See [Package digests](#package-digests). ### SARIF [Static Analysis Results Interchange Format](https://sarifweb.azurewebsites.net/) (SARIF) 2.1.0. GitHub code scanning and Azure DevOps read this file. It contains findings only. The DriftScore and the other metrics stay in the JSON artifact. Drift findings come first. Vulnerability findings follow when the scan ran with `--vulns`. +`artifactLocation.uri` uses the same relative paths as the JSON artifact. A finding id (`vg/finding-id/v1`) is computed from that relative location, so two machines scanning the same tree agree on the id. + Every result includes `partialFingerprints`. Code scanning uses that object to recognize the same finding on a later run, so an unchanged finding stays one alert. The scan time, the result order, and the artifact timestamp are not part of the value. The message text is left as the scanner wrote it. ```bash @@ -4872,7 +4876,7 @@ A clean Markdown report suitable for PRs, wikis, or documentation. ### JUnit -`--junit ` writes a JUnit XML report next to whatever `--format` you selected (`text`, `json`, `sarif`, or `md`). GitLab (`artifacts:reports:junit`), Azure DevOps, and Jenkins can publish it without a second scan. The file is local: rule ids, locations, messages, and gate results only — no DSN, repository URL, or scan clock. +`--junit ` writes a JUnit XML report next to whatever `--format` you selected (`text`, `json`, `sarif`, or `md`). GitLab (`artifacts:reports:junit`), Azure DevOps, and Jenkins can publish it without a second scan. The file is local: rule ids, locations, messages, and gate results only — no DSN, repository URL, or scan clock. Locations follow the same relative and `~/` rules as the JSON artifact. The same findings and gates always produce the same bytes. Case order is fixed, `time` is `0`, and the optional JUnit `timestamp` attribute is omitted. diff --git a/src/commands/build.ts b/src/commands/build.ts index 981cfa95..527b567c 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -27,6 +27,7 @@ import { CliError, ExitCode, usageError } from '../util/exit.js'; import { resolveSelfJsEntry } from '../util/cli-invocation.js'; import { c, info, out, json } from '../util/output.js'; import { formatWarningLine } from '../core-open/warnings.js'; +import { redactHomePaths } from '../core-open/utils/shareable-path.js'; import { printLogo } from '../util/logo.js'; import { ProgressBar } from '../util/progress.js'; import { applyGlobalOptions, readGlobal, type GlobalOpts } from '../cli-options.js'; @@ -261,7 +262,7 @@ export async function runBuild( } if (global.json) { - json({ + json(redactHomePaths({ ok: true, counts: result.graph.meta.counts, languages: result.graph.meta.languages, @@ -284,7 +285,7 @@ export async function runBuild( warnings: result.warnings, ...(result.codedWarnings.length > 0 ? { codedWarnings: result.codedWarnings } : {}), activity: activity.toJSON(), - }); + }, root)); return; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 463b6414..a29a6a25 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -19,6 +19,7 @@ import { gatherSystemMemory } from '../code/local-runtime.js'; import { buildLocalInferenceStatus, type LocalInferenceStatus } from '../runtime/local-inference-status.js'; import { VERSION } from '../version.js'; import { findConfigFile, readDataConfigSync, shadowedConfigFiles } from '../core-open/config.js'; +import { redactHomePaths } from '../core-open/utils/shareable-path.js'; import { c, info, json } from '../util/output.js'; import { applyGlobalOptions, readGlobal, type GlobalOpts } from '../cli-options.js'; import { rootOf } from './util.js'; @@ -218,7 +219,7 @@ async function runDoctor(global: GlobalOpts): Promise { }; if (global.json) { - json(d); + json(redactHomePaths(d, root)); return; } diff --git a/src/core-open/formatters/markdown.ts b/src/core-open/formatters/markdown.ts index a9e6820f..e0b2c72e 100644 --- a/src/core-open/formatters/markdown.ts +++ b/src/core-open/formatters/markdown.ts @@ -4,6 +4,7 @@ import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../baseline-suppressions.js'; import { findingRuleLabel, formatDegradationLines } from '../warnings.js'; +import { redactHomePaths } from '../utils/shareable-path.js'; import { securityPacksLabel } from './text.js'; /** Rows shown before the infrastructure-findings table is cut with an "… N more" line. */ @@ -24,7 +25,8 @@ function markdownDriftCell(score: number | null): string { } /** Generate a Markdown report from scan artifact */ -export function formatMarkdown(artifact: ScanArtifact): string { +export function formatMarkdown(artifact: ScanArtifact, root?: string): string { + artifact = redactHomePaths(artifact, root); const lines: string[] = []; // Billing (micro-project pricing) is a commercial signal attached by the full diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index e1723791..f5b0f993 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { baselineSuppressionId } from '../baseline-suppressions.js'; import type { BaselineSuppression, ScanArtifact, Finding, SecurityFinding, SecuritySection, SecuritySeverity } from '../types.js'; +import { redactHomePaths } from '../utils/shareable-path.js'; /** * SARIF `partialFingerprints` key for every result `vg scan` emits. @@ -22,7 +23,10 @@ const FINDING_FINGERPRINT_KEY = 'vg/finding-id/v1'; * listed under `tool.extensions`. A scan that ran no pack is still one run, * and an empty pack section does not change the document. */ -export function formatSarif(artifact: ScanArtifact): object { +export function formatSarif(artifact: ScanArtifact, root?: string): object { + // Fingerprints and baseline ids use the location, so rewrite home paths + // before either is computed. A second pass sees only relative or `~/` paths. + artifact = redactHomePaths(artifact, root); const suppressed = suppressionIndex(artifact); const rules = buildRules(artifact.findings); const results = artifact.findings.map((f) => toSarifResult(f, suppressed.get(suppressionKey(f.ruleId, f.location)))); diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index 4ae6ee57..ab5b0247 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -37,6 +37,7 @@ import { formatSarif } from './formatters/sarif.js'; import { formatMarkdown } from './formatters/markdown.js'; import { loadConfig, appendExcludePatterns } from './config.js'; import { pathExists, readJsonFile, writeJsonFile, writeTextFile, ensureDir, FileCache, quickTreeCount } from './utils/fs.js'; +import { redactHomePaths } from './utils/shareable-path.js'; import { assertSafeWalkRoot } from './utils/root-safety.js'; import { detectVcs } from './utils/vcs.js'; import { isCiEnvironment, hasVibgrateWorkflow } from './utils/ci-env.js'; @@ -874,12 +875,17 @@ export async function runCoreScan( } } + // Machine-readable artifacts are shareable: home prefixes become paths + // relative to the scan root, or `~/…` when the path sits outside it. + // Terminal text keeps the original locations. + const shareable = redactHomePaths(artifact, rootDir); + if (!opts.noLocalArtifacts && !maxPrivacyMode) { const vibgrateDir = path.join(rootDir, '.vibgrate'); await ensureDir(vibgrateDir); - await writeJsonFile(path.join(vibgrateDir, 'scan_result.json'), artifact); - await writeJsonFile(path.join(vibgrateDir, 'solutions.json'), { - scannedAt: artifact.timestamp, + await writeJsonFile(path.join(vibgrateDir, 'scan_result.json'), shareable); + await writeJsonFile(path.join(vibgrateDir, 'solutions.json'), redactHomePaths({ + scannedAt: shareable.timestamp, solutions: solutions.map((solution) => ({ solutionId: solution.solutionId, name: solution.name, @@ -887,7 +893,7 @@ export async function runCoreScan( type: solution.type, projectPaths: solution.projectPaths, })), - }); + }, rootDir)); } // scan_history.json is local ETA telemetry, not a result — honour the same @@ -926,12 +932,12 @@ export async function runCoreScan( if (Object.keys(projectScores).length > 0) { const vibgrateDir = path.join(rootDir, '.vibgrate'); await ensureDir(vibgrateDir); - await writeJsonFile(path.join(vibgrateDir, 'project_scores.json'), projectScores); + await writeJsonFile(path.join(vibgrateDir, 'project_scores.json'), redactHomePaths(projectScores, rootDir)); } } if (opts.format === 'json') { - const jsonStr = JSON.stringify(artifact, null, 2); + const jsonStr = JSON.stringify(shareable, null, 2); if (opts.out) { await writeTextFile(path.resolve(opts.out), jsonStr); console.log(chalk.green('✔') + ` JSON written to ${opts.out}`); @@ -939,7 +945,7 @@ export async function runCoreScan( console.log(jsonStr); } } else if (opts.format === 'sarif') { - const sarif = formatSarif(artifact); + const sarif = formatSarif(shareable, rootDir); const sarifStr = JSON.stringify(sarif, null, 2); if (opts.out) { await writeTextFile(path.resolve(opts.out), sarifStr); @@ -948,7 +954,7 @@ export async function runCoreScan( console.log(sarifStr); } } else if (opts.format === 'md') { - const markdown = formatMarkdown(artifact); + const markdown = formatMarkdown(shareable, rootDir); console.log(markdown); if (opts.out) { await writeTextFile(path.resolve(opts.out), markdown); @@ -991,7 +997,7 @@ export async function runCoreScan( } } - return artifact; + return shareable; } async function buildRepositoryInfo(rootDir: string, remoteUrl: string | undefined, ciSystems: string[] | undefined, nameOverride?: string): Promise { diff --git a/src/core-open/utils/shareable-path.ts b/src/core-open/utils/shareable-path.ts new file mode 100644 index 00000000..5b29e67f --- /dev/null +++ b/src/core-open/utils/shareable-path.ts @@ -0,0 +1,133 @@ +/** + * Rewrite absolute home-directory paths so machine-readable artifacts can be + * shared. A path under `root` becomes POSIX-relative (`.` for the root + * itself). A path under `/home/`, `/Users/`, or + * `:/Users/` that is outside `root` becomes `~/…`. + * + * This does not call `os.homedir()`. Two machines with the same tree under + * different home prefixes produce the same relative strings. A second pass + * is a no-op: relative paths and `~/…` contain no home prefix. + * + * Strings that are not home paths are left unchanged: relative paths, + * `/test/…`, package names, URLs, and opaque blobs that only contain the + * letters `/home/` in the middle of a token. + */ +import * as path from 'node:path'; + +const HOME_MARK = /\/(?:home|Users)\/|[A-Za-z]:[\\/]Users[\\/]/; + +const USER = String.raw`[A-Za-z0-9._-]{1,64}`; +const SEG = String.raw`(?:\/[A-Za-z0-9._@+-]*)*`; +const CLIMB = String.raw`(?:\.\.\/)+`; +const CLIMB_OPT = String.raw`(?:\.\.\/)*`; +/** Absolute `/home/…`, `/Users/…`, or `C:/Users/…`, optional `../` prefix dropped on rewrite. */ +const ABS = String.raw`(?:\/(?:home|Users)|[A-Za-z]:\/Users)\/${USER}${SEG}`; +/** `../` climb that `path.relative` emits for a home path outside the scan root. */ +const REL_HOME = String.raw`(?:home|Users)\/${USER}${SEG}`; + +const WHOLE = new RegExp(`^(?:(${CLIMB})(${REL_HOME})|(${CLIMB_OPT})(${ABS}))$`); +const EMBEDDED = new RegExp( + `(?:^|(?<=[\\s"'=\`(\\[{/]))(?:(${CLIMB})(${REL_HOME})|(${CLIMB_OPT})(${ABS}))`, + 'g', +); + +const HOME_PREFIX = /^(\/(?:home|Users)\/(?!\.{1,2}(?:\/|$))[^/]+|[A-Za-z]:\/Users\/(?!\.{1,2}(?:\/|$))[^/]+)/; + +function stripTrail(p: string): string { + if (p.length > 1 && p.endsWith('/')) return p.replace(/\/+$/, ''); + return p; +} + +/** POSIX form. A drive letter is kept as written; the rest is `path.posix.normalize`. */ +function canonicalPosix(p: string): string { + const drive = /^([A-Za-z]:)(\/.*)$/.exec(p); + if (drive) { + let rest = path.posix.normalize(drive[2]); + if (!rest.startsWith('/')) rest = `/${rest}`; + return `${drive[1]}${rest}`; + } + return path.posix.normalize(p); +} + +function canonicalRoot(root: string): string { + const posix = root.replace(/\\/g, '/'); + if (posix.startsWith('/') || /^[A-Za-z]:\//.test(posix)) return stripTrail(canonicalPosix(posix)); + return stripTrail(canonicalPosix(path.resolve(root).replace(/\\/g, '/'))); +} + +function rewriteHomePath(abs: string, root?: string): string { + const canon = stripTrail(canonicalPosix(abs)); + const home = HOME_PREFIX.exec(canon); + if (!home) return canon; + if (root) { + const canonRoot = canonicalRoot(root); + if (canon === canonRoot) return '.'; + if (canon.startsWith(`${canonRoot}/`)) return canon.slice(canonRoot.length + 1); + } + if (canon === home[1]) return '~'; + return `~${canon.slice(home[1].length)}`; +} + +function rewriteMatch(relHome: string | undefined, abs: string | undefined, root?: string): string { + if (relHome) return rewriteHomePath(`/${relHome}`, root); + return rewriteHomePath(abs ?? '', root); +} + +/** + * Rewrite one string. `root`, when set, is the scan or build directory: + * home paths inside it become relative to that directory. + */ +export function shareablePath(input: string, root?: string): string { + if (!HOME_MARK.test(input)) return input; + const slash = input.replace(/\\/g, '/'); + let rewritten: string; + if (!/\s/.test(slash)) { + const whole = WHOLE.exec(slash); + if (whole) { + rewritten = rewriteMatch(whole[2], whole[4], root); + return rewritten; + } + } + EMBEDDED.lastIndex = 0; + rewritten = slash.replace(EMBEDDED, (_m, _climb: string, relHome: string, _climbAbs: string, abs: string) => + rewriteMatch(relHome, abs, root), + ); + return rewritten === slash ? input : rewritten; +} + +function isPlainObject(value: object): boolean { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function walk(value: unknown, root?: string): unknown { + if (typeof value === 'string') return shareablePath(value, root); + if (Array.isArray(value)) return value.map((item) => walk(item, root)); + if (value !== null && typeof value === 'object' && isPlainObject(value)) { + const out: Record = {}; + for (const key of Object.keys(value)) { + out[shareablePath(key, root)] = walk((value as Record)[key], root); + } + return out; + } + return value; +} + +/** + * Deep-copy `value`, rewriting home-directory paths in strings and object keys. + * Key order is preserved. The input is not mutated. + */ +export function redactHomePaths(value: T, root?: string): T { + return walk(value, root) as T; +} + +/** + * Scan root for an artifact file that lives in a `.vibgrate` directory + * (the parent of that directory). Other files have no inferred root, so a + * fixture whose `rootPath` is already a logical path is left unchanged. + */ +export function scanRootFromArtifactFile(artifactPath: string): string | undefined { + const dir = path.dirname(path.resolve(artifactPath)); + if (path.basename(dir) === '.vibgrate') return path.dirname(dir); + return undefined; +} diff --git a/src/reporting/commands/report.ts b/src/reporting/commands/report.ts index bd984159..3e663342 100644 --- a/src/reporting/commands/report.ts +++ b/src/reporting/commands/report.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { Command } from 'commander'; import chalk from 'chalk'; import { usageError } from '../../util/exit.js'; +import { redactHomePaths, scanRootFromArtifactFile } from '../../core-open/utils/shareable-path.js'; import { pathExists, readJsonFile } from '../utils/fs.js'; import { formatText } from '../formatters/text.js'; import { formatMarkdown } from '../formatters/markdown.js'; @@ -33,19 +34,24 @@ export const reportCommand = new Command('report') } const artifact = await readJsonFile(artifactPath); + // Only a result written under `.vibgrate/` has a known scan root. A file + // passed in from elsewhere keeps its paths unless they are home prefixes, + // which still collapse to `~/…` inside the formatters. + const shareRoot = scanRootFromArtifactFile(artifactPath); + const shareable = redactHomePaths(artifact, shareRoot); switch (opts.format) { case 'md': - console.log(formatMarkdown(artifact)); + console.log(formatMarkdown(shareable, shareRoot)); break; case 'json': - console.log(JSON.stringify(artifact, null, 2)); + console.log(JSON.stringify(shareable, null, 2)); break; case 'text': console.log(formatText(artifact)); break; case 'html': - console.log(formatHtmlReport(artifact)); + console.log(formatHtmlReport(shareable, shareRoot)); break; } }); diff --git a/src/reporting/commands/scan.ts b/src/reporting/commands/scan.ts index f23006a0..83c72556 100644 --- a/src/reporting/commands/scan.ts +++ b/src/reporting/commands/scan.ts @@ -841,6 +841,7 @@ export const scanCommand = new Command('scan') findings: artifact.findings, driftGate: failOn.legacy === 'warn' || failOn.legacy === 'error' ? failOn.legacy : undefined, extraSuites: junitSuites, + root: rootDir, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -864,7 +865,7 @@ export const scanCommand = new Command('scan') if (found === null) { junitSuites.push(architectureGateSuite({ status: 'blocked', message: ARCHITECTURE_NOT_CLASSIFIED })); } else if (found.rows.length > 0) { - junitSuites.push(architectureGateSuite({ status: 'failed', rows: found.rows })); + junitSuites.push(architectureGateSuite({ status: 'failed', rows: found.rows }, rootDir)); } else { junitSuites.push(architectureGateSuite({ status: 'clean' })); } @@ -888,7 +889,7 @@ export const scanCommand = new Command('scan') status: 'evaluated', findings: section.findings, failingIds: outcome.matched.map((f) => f.id), - })); + }, rootDir)); } } } diff --git a/src/reporting/formatters/html.ts b/src/reporting/formatters/html.ts index e1c98703..4df6bef5 100644 --- a/src/reporting/formatters/html.ts +++ b/src/reporting/formatters/html.ts @@ -1,5 +1,6 @@ import { fixAvailableHint } from '../../core-open/formatters/fix-hint.js'; import { redactForDisplay } from '../../core-open/utils/redact.js'; +import { redactHomePaths } from '../../core-open/utils/shareable-path.js'; import type { SecurityFinding } from '../../core-open/types.js'; import type { Finding, ScanArtifact } from '../types.js'; @@ -7,7 +8,8 @@ import type { Finding, ScanArtifact } from '../types.js'; * Self-contained HTML for `vg report --format html`. * * Pure function of the artifact: stable sorts, no clock, no randomness, no - * network, no external assets. Artifact strings are redacted, then escaped. + * network, no external assets. Home-directory paths are rewritten to a + * repo-relative or `~/` form, then artifact strings are secret-redacted and escaped. * * Stable ids: `#summary`, `#vulnerabilities`, `#security`, `#findings`. * @@ -42,7 +44,8 @@ const VULN_RANK: Record = { const LEVEL_RANK: Record = { error: 0, warning: 1, note: 2 }; -export function formatHtmlReport(artifact: ScanArtifact): string { +export function formatHtmlReport(artifact: ScanArtifact, root?: string): string { + artifact = redactHomePaths(artifact, root); const findings = Array.isArray(artifact.findings) ? artifact.findings : []; const vulnFindings = findings.filter((f) => f.ruleId === VULN_RULE_ID); const otherFindings = findings.filter((f) => f.ruleId !== VULN_RULE_ID); diff --git a/src/reporting/formatters/markdown.ts b/src/reporting/formatters/markdown.ts index d6d2d0aa..5c1c907e 100644 --- a/src/reporting/formatters/markdown.ts +++ b/src/reporting/formatters/markdown.ts @@ -1,6 +1,7 @@ import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../../core-open/baseline-suppressions.js'; import { findingRuleLabel, formatDegradationLines } from '../../core-open/warnings.js'; +import { redactHomePaths } from '../../core-open/utils/shareable-path.js'; /** A measured zero stays `0`. An unmeasured component is `n/a`, never `0`. */ function markdownDriftCell(score: number | null): string { @@ -8,7 +9,8 @@ function markdownDriftCell(score: number | null): string { } /** Generate a Markdown report from scan artifact */ -export function formatMarkdown(artifact: ScanArtifact): string { +export function formatMarkdown(artifact: ScanArtifact, root?: string): string { + artifact = redactHomePaths(artifact, root); const lines: string[] = []; lines.push('# Vibgrate Drift Report'); diff --git a/src/reporting/junit-report.ts b/src/reporting/junit-report.ts index e88f6905..65140293 100644 --- a/src/reporting/junit-report.ts +++ b/src/reporting/junit-report.ts @@ -4,7 +4,9 @@ * The report summarizes drift findings and the gates `vg scan` actually * judges (drift `--fail-on`, architecture, security packs, drift budget). * It is a pure function of that input: no clock, no host name, no DSN, no - * repository URL. The optional JUnit `timestamp` attribute is omitted; + * repository URL. Home-directory paths are rewritten the same way as the + * JSON artifact (relative to the scan root, or `~/…`). The optional JUnit + * `timestamp` attribute is omitted; * `time` is always `0` because wall-clock duration is not part of the result. * * Suites, in order: @@ -19,6 +21,7 @@ * drift gate; otherwise it is ``. Notes never fail a gate. */ import * as path from 'node:path'; +import { shareablePath } from '../core-open/utils/shareable-path.js'; import { compareDriftBudget, type DriftBudgetGateResult } from './drift-budget-gate.js'; import { writeTextFile } from './utils/fs.js'; @@ -73,6 +76,8 @@ export interface ScanJUnitInput { driftGate?: 'warn' | 'error'; /** Appended after the findings suite, in the order given. Empty suites are dropped. */ extraSuites?: readonly JUnitSuite[]; + /** Scan directory. Home paths inside it are written relative to this directory. */ + root?: string; } export interface ArchitectureJUnitRow { @@ -146,8 +151,16 @@ function findingBody(f: JUnitFinding): string { return `${f.message}\nrule: ${f.ruleId}\nlocation: ${f.location}\nlevel: ${f.level}`; } -export function findingsSuite(findings: readonly JUnitFinding[], driftGate?: 'warn' | 'error'): JUnitSuite { - const sorted = [...findings].sort(compareFindings); +export function findingsSuite( + findings: readonly JUnitFinding[], + driftGate?: 'warn' | 'error', + root?: string, +): JUnitSuite { + const sorted = findings.map((f) => ({ + ...f, + location: shareablePath(f.location, root), + message: shareablePath(f.message, root), + })).sort(compareFindings); if (sorted.length === 0) { return { name: 'findings', cases: [pass(JUNIT_FINDINGS_CLASS, 'no findings')] }; } @@ -170,17 +183,22 @@ export function architectureGateSuite( | { status: 'blocked'; message: string } | { status: 'clean' } | { status: 'failed'; rows: readonly ArchitectureJUnitRow[] }, + root?: string, ): JUnitSuite { if (input.status === 'blocked') { return { name: 'architecture', - cases: [failure(JUNIT_ARCHITECTURE_CLASS, 'architecture', 'gate', input.message)], + cases: [failure(JUNIT_ARCHITECTURE_CLASS, 'architecture', 'gate', shareablePath(input.message, root))], }; } if (input.status === 'clean') { return { name: 'architecture', cases: [pass(JUNIT_ARCHITECTURE_CLASS, 'architecture')] }; } - const rows = [...input.rows].sort((a, b) => + const rows = input.rows.map((r) => ({ + ...r, + file: shareablePath(r.file, root), + message: shareablePath(r.message, root), + })).sort((a, b) => cmp(a.file, b.file) || (a.line ?? 0) - (b.line ?? 0) || cmp(a.symbol, b.symbol) @@ -210,15 +228,20 @@ export function securityGateSuite( input: | { status: 'blocked'; message: string } | { status: 'evaluated'; findings: readonly SecurityJUnitFinding[]; failingIds: readonly string[] }, + root?: string, ): JUnitSuite { if (input.status === 'blocked') { return { name: 'security', - cases: [failure(JUNIT_SECURITY_CLASS, 'security', 'gate', input.message)], + cases: [failure(JUNIT_SECURITY_CLASS, 'security', 'gate', shareablePath(input.message, root))], }; } const failing = new Set(input.failingIds); - const findings = [...input.findings].sort((a, b) => + const findings = input.findings.map((f) => ({ + ...f, + path: shareablePath(f.path, root), + message: shareablePath(f.message, root), + })).sort((a, b) => cmp(a.path, b.path) || (a.line ?? 0) - (b.line ?? 0) || cmp(a.rule, b.rule) @@ -311,7 +334,7 @@ export function configDriftBudgetCases(gate: DriftBudgetGateResult): JUnitTestCa } export function buildScanJUnitReport(input: ScanJUnitInput): JUnitReport { - const suites: JUnitSuite[] = [findingsSuite(input.findings, input.driftGate)]; + const suites: JUnitSuite[] = [findingsSuite(input.findings, input.driftGate, input.root)]; for (const suite of input.extraSuites ?? []) { if (suite.cases.length > 0) suites.push(suite); } diff --git a/test/shareable-paths.test.ts b/test/shareable-paths.test.ts new file mode 100644 index 00000000..0521a6d2 --- /dev/null +++ b/test/shareable-paths.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; +import { formatMarkdown as formatMarkdownCore } from '../src/core-open/formatters/markdown.js'; +import { formatSarif } from '../src/core-open/formatters/sarif.js'; +import type { Finding, ScanArtifact } from '../src/core-open/types.js'; +import { redactHomePaths, scanRootFromArtifactFile, shareablePath } from '../src/core-open/utils/shareable-path.js'; +import { formatHtmlReport } from '../src/reporting/formatters/html.js'; +import { formatMarkdown } from '../src/reporting/formatters/markdown.js'; +import { findingsSuite, renderJUnitReport, securityGateSuite } from '../src/reporting/junit-report.js'; + +/** + * One tree, two machines. `root` is the scan directory. `cache` and `climbed` + * are the same files outside that directory, written the way each machine + * would spell them. + */ +function artifactAt(root: string, cache: string, climbed: string): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp: '2026-01-01T00:00:00.000Z', + vibgrateVersion: '0.0.0-test', + rootPath: root, + projects: [ + { + type: 'node', + path: root, + name: 'app', + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }, + ], + drift: { + score: 10, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, + }, + findings: [ + finding(`${root}/package.json`, `lockfile ${root}/package.json is old`), + finding(cache, 'cache lives outside the scan root'), + finding(climbed, 'climbed out of the scan root'), + ], + extended: { + security: { + schema: 'vg.security.v1', + engine: 'test', + packs: { iac: '1' }, + facts: { received: 1, evaluated: 1, rejected: 0 }, + findings: [ + { + id: 'a'.repeat(32), + pack: 'iac', + packVersion: '1', + rule: 'public-bucket', + severity: 'high', + message: `open bucket in ${root}/infra/main.tf`, + path: `${root}/infra/main.tf`, + }, + ], + }, + }, + }; +} + +function finding(location: string, message: string): Finding { + return { ruleId: 'vibgrate/runtime-lag', level: 'warning', message, location }; +} + +const DEV = artifactAt( + '/home/dev/app', + '/home/dev/.cache/vibgrate/cas', + '../../../home/dev/.local/state/graph.json', +); +const CASEY = artifactAt( + '/Users/casey/app', + '/Users/casey/.cache/vibgrate/cas', + '../../../Users/casey/.local/state/graph.json', +); + +function machineReadable(scan: ScanArtifact, root: string): string { + const json = JSON.stringify(redactHomePaths(scan, root)); + const sarif = JSON.stringify(formatSarif(scan, root)); + const md = `${formatMarkdownCore(scan, root)}\n${formatMarkdown(scan, root)}`; + const html = formatHtmlReport(scan, root); + const junit = renderJUnitReport({ + name: 'vg scan', + suites: [ + findingsSuite(scan.findings, 'warn', root), + securityGateSuite({ + status: 'evaluated', + findings: scan.extended!.security!.findings, + failingIds: [scan.extended!.security!.findings[0]!.id], + }, root), + ], + }); + return [json, sarif, md, html, junit].join('\n'); +} + +describe('shareable home paths', () => { + it('drops home prefixes from default machine-readable output', () => { + const text = machineReadable(DEV, '/home/dev/app') + machineReadable(CASEY, '/Users/casey/app'); + expect(text).not.toContain('/home/dev'); + expect(text).not.toContain('/Users/casey'); + expect(text).not.toContain('../../../home/'); + expect(text).not.toContain('../../../Users/'); + + const shareable = redactHomePaths(DEV, '/home/dev/app'); + expect(shareable.rootPath).toBe('.'); + expect(shareable.projects[0]?.path).toBe('.'); + expect(shareable.findings.map((f) => f.location)).toEqual([ + 'package.json', + '~/.cache/vibgrate/cas', + '~/.local/state/graph.json', + ]); + expect(shareable.extended?.security?.findings[0]?.path).toBe('infra/main.tf'); + expect(shareable.findings[0]?.message).toBe('lockfile package.json is old'); + expect(DEV.findings[0]?.location).toBe('/home/dev/app/package.json'); + }); + + it('produces the same relative artifact from two home prefixes', () => { + const alice = artifactAt( + '/home/alice/work', + '/home/alice/.cache/vibgrate/cas', + '../../../home/alice/.local/state/graph.json', + ); + const bob = artifactAt( + '/Users/bob/work', + '/Users/bob/.cache/vibgrate/cas', + '../../../Users/bob/.local/state/graph.json', + ); + expect(redactHomePaths(alice, '/home/alice/work')).toEqual(redactHomePaths(bob, '/Users/bob/work')); + expect(formatSarif(alice, '/home/alice/work')).toEqual(formatSarif(bob, '/Users/bob/work')); + const once = redactHomePaths(alice, '/home/alice/work'); + expect(redactHomePaths(once, '/home/alice/work')).toEqual(once); + }); + + it('leaves non-home paths, URLs, and opaque tokens unchanged', () => { + expect(shareablePath('/test/app')).toBe('/test/app'); + expect(shareablePath('/fixture')).toBe('/fixture'); + expect(shareablePath('https://github.com/home/dev/app')).toBe('https://github.com/home/dev/app'); + expect(shareablePath('AAAA/home/abcd/BBBB')).toBe('AAAA/home/abcd/BBBB'); + expect(shareablePath('/home/../etc/passwd')).toBe('/etc/passwd'); + expect(shareablePath('package.json', '/home/dev/app')).toBe('package.json'); + expect(shareablePath('see https://github.com/home/dev/app and /home/dev/app/package.json', '/home/dev/app')) + .toBe('see https://github.com/home/dev/app and package.json'); + + const kept = artifactAt('/test/app', '/test/cache', 'notes'); + kept.findings = [finding('/test/app', 'kept')]; + kept.extended = undefined; + const sarif = JSON.stringify(formatSarif(kept)); + expect(sarif).toContain('/test/app'); + }); + + it('rewrites Windows home paths and paths that only share a prefix', () => { + expect(shareablePath('C:\\Users\\casey\\app\\src\\a.ts', 'C:/Users/casey/app')).toBe('src/a.ts'); + expect(shareablePath('/home/dev/app/src/c++/main.cpp', '/home/dev/app')).toBe('src/c++/main.cpp'); + expect(shareablePath('/home/dev/app-extra/file.ts', '/home/dev/app')).toBe('~/app-extra/file.ts'); + expect(shareablePath('/home/dev', '/home/dev/app')).toBe('~'); + }); + + it('infers a scan root only for artifacts stored under .vibgrate', () => { + expect(scanRootFromArtifactFile('/home/dev/app/.vibgrate/scan_result.json')).toBe('/home/dev/app'); + expect(scanRootFromArtifactFile('/tmp/scan_result.json')).toBeUndefined(); + }); + + it('rewrites build and doctor JSON fields', () => { + const built = redactHomePaths({ + cas: { dir: '/home/dev/.cache/vibgrate/cas' }, + artifacts: { graphPath: '/home/dev/app/.vibgrate/graph.json', reportPath: '/home/dev/app/.vibgrate/report.md' }, + }, '/home/dev/app'); + expect(built.cas.dir).toBe('~/.cache/vibgrate/cas'); + expect(built.artifacts.graphPath).toBe('.vibgrate/graph.json'); + expect(built.artifacts.reportPath).toBe('.vibgrate/report.md'); + + const diagnosis = redactHomePaths({ + root: '/home/dev/app', + map: { path: '/home/dev/.local/state/vibgrate/graph.json' }, + }, '/home/dev/app'); + expect(diagnosis.root).toBe('.'); + expect(diagnosis.map.path).toBe('~/.local/state/vibgrate/graph.json'); + }); +}); From c7c2fca97ffc0242641f20a36412a03ea0104b68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 13:07:59 +0000 Subject: [PATCH 2/2] fix: drop home-directory prefixes from machine-readable output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A path inside the scan or build root is written relative to that root. A path under the same home but outside the root is written as `../…`. Any other `/Users//` or `/home//` prefix is removed, and the result does not use `~/`. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 7 + DOCS.md | 8 +- src/commands/build.ts | 5 +- src/commands/doctor.ts | 3 +- src/commands/export.ts | 1 + src/core-open/formatters/markdown.ts | 4 +- src/core-open/formatters/sarif.ts | 6 +- src/core-open/run-core-scan.ts | 33 +++-- src/core-open/utils/portable-path.ts | 97 ++++++++++++++ src/core-open/utils/shareable-path.ts | 133 ------------------- src/engine/artifacts.ts | 8 +- src/engine/export.ts | 11 +- src/engine/html.ts | 4 +- src/engine/report.ts | 4 +- src/engine/serialize.ts | 5 +- src/reporting/commands/report.ts | 15 +-- src/reporting/commands/scan.ts | 5 +- src/reporting/formatters/html.ts | 7 +- src/reporting/formatters/markdown.ts | 4 +- src/reporting/junit-report.ts | 39 ++---- test/portable-output-paths.test.ts | 135 +++++++++++++++++++ test/shareable-paths.test.ts | 180 -------------------------- 22 files changed, 305 insertions(+), 409 deletions(-) create mode 100644 src/core-open/utils/portable-path.ts delete mode 100644 src/core-open/utils/shareable-path.ts create mode 100644 test/portable-output-paths.test.ts delete mode 100644 test/shareable-paths.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 034675d6..87cb3119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ backward compatible. ### Changed +- **Machine-readable output drops absolute home-directory paths (#282).** + JSON, SARIF, and reports rewrite `/Users//` and `/home//` + paths. A path inside the scan or build root is written relative to that + root. A path under the same home directory but outside the root is written + as `../…`. Any other path only loses that prefix. The result does not use + `~/`. The same tree produces the same paths. + - **`vg build` and `vg scan` do not follow symlinks, and they say so.** A directory symlink is not entered, so a link to its parent cannot hang the walk, and a file symlink is not read. When any are skipped, one stderr diff --git a/DOCS.md b/DOCS.md index 0bbc5d4b..7f9fb200 100644 --- a/DOCS.md +++ b/DOCS.md @@ -4571,7 +4571,7 @@ vg doctor --local | Flag | Description | |------|-------------| -| `--json` | Machine-readable JSON on stdout. The scanned directory is `.`. Other paths follow the [JSON artifact](#json-artifact) relative and `~/` rules. | +| `--json` | Machine-readable JSON on stdout | | `--offline` | Skip the hosted reachability probe | | `-C, --cwd ` | Run as if started in that directory | @@ -4770,16 +4770,12 @@ The default output. A coloured, human-readable report showing: The full scan artifact in JSON format. Contains all raw data, scores, findings, and VCS metadata. Stable schema (`schemaVersion: "1.0"`). This is the same artifact saved to `.vibgrate/scan_result.json`. -Paths in this artifact are relative to the scanned directory. A path inside that directory has no leading slash (`package.json`, or `.` for the directory itself). A path outside it whose absolute form is under `/home/`, `/Users/`, or `:/Users/` is written as `~/…`. The same tree produces the same relative strings on every machine. `vg build --json` and `vg doctor --json` use the same rule for paths they emit (`artifacts`, `cas.dir`, `root`, and any other absolute home path in that document). Terminal text is left as recorded, so a local debugging session still shows a path you can open. - Dependency identity in this file is `projects[].type` plus `dependencies[].package` and `resolvedVersion`. An advisory match is `extended.vulnerabilities.packages[]` (`ecosystem`, `package`, `version`). `vg sbom export` writes those coordinates as a package URL. The file has no CPE field. See [Component identity](#component-identity). A dependency row has no package digest. `projects[].projectId` and `solutions[].solutionId` identify the project and the solution. See [Package digests](#package-digests). ### SARIF [Static Analysis Results Interchange Format](https://sarifweb.azurewebsites.net/) (SARIF) 2.1.0. GitHub code scanning and Azure DevOps read this file. It contains findings only. The DriftScore and the other metrics stay in the JSON artifact. Drift findings come first. Vulnerability findings follow when the scan ran with `--vulns`. -`artifactLocation.uri` uses the same relative paths as the JSON artifact. A finding id (`vg/finding-id/v1`) is computed from that relative location, so two machines scanning the same tree agree on the id. - Every result includes `partialFingerprints`. Code scanning uses that object to recognize the same finding on a later run, so an unchanged finding stays one alert. The scan time, the result order, and the artifact timestamp are not part of the value. The message text is left as the scanner wrote it. ```bash @@ -4876,7 +4872,7 @@ A clean Markdown report suitable for PRs, wikis, or documentation. ### JUnit -`--junit ` writes a JUnit XML report next to whatever `--format` you selected (`text`, `json`, `sarif`, or `md`). GitLab (`artifacts:reports:junit`), Azure DevOps, and Jenkins can publish it without a second scan. The file is local: rule ids, locations, messages, and gate results only — no DSN, repository URL, or scan clock. Locations follow the same relative and `~/` rules as the JSON artifact. +`--junit ` writes a JUnit XML report next to whatever `--format` you selected (`text`, `json`, `sarif`, or `md`). GitLab (`artifacts:reports:junit`), Azure DevOps, and Jenkins can publish it without a second scan. The file is local: rule ids, locations, messages, and gate results only — no DSN, repository URL, or scan clock. The same findings and gates always produce the same bytes. Case order is fixed, `time` is `0`, and the optional JUnit `timestamp` attribute is omitted. diff --git a/src/commands/build.ts b/src/commands/build.ts index 527b567c..981cfa95 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -27,7 +27,6 @@ import { CliError, ExitCode, usageError } from '../util/exit.js'; import { resolveSelfJsEntry } from '../util/cli-invocation.js'; import { c, info, out, json } from '../util/output.js'; import { formatWarningLine } from '../core-open/warnings.js'; -import { redactHomePaths } from '../core-open/utils/shareable-path.js'; import { printLogo } from '../util/logo.js'; import { ProgressBar } from '../util/progress.js'; import { applyGlobalOptions, readGlobal, type GlobalOpts } from '../cli-options.js'; @@ -262,7 +261,7 @@ export async function runBuild( } if (global.json) { - json(redactHomePaths({ + json({ ok: true, counts: result.graph.meta.counts, languages: result.graph.meta.languages, @@ -285,7 +284,7 @@ export async function runBuild( warnings: result.warnings, ...(result.codedWarnings.length > 0 ? { codedWarnings: result.codedWarnings } : {}), activity: activity.toJSON(), - }, root)); + }); return; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index a29a6a25..463b6414 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -19,7 +19,6 @@ import { gatherSystemMemory } from '../code/local-runtime.js'; import { buildLocalInferenceStatus, type LocalInferenceStatus } from '../runtime/local-inference-status.js'; import { VERSION } from '../version.js'; import { findConfigFile, readDataConfigSync, shadowedConfigFiles } from '../core-open/config.js'; -import { redactHomePaths } from '../core-open/utils/shareable-path.js'; import { c, info, json } from '../util/output.js'; import { applyGlobalOptions, readGlobal, type GlobalOpts } from '../cli-options.js'; import { rootOf } from './util.js'; @@ -219,7 +218,7 @@ async function runDoctor(global: GlobalOpts): Promise { }; if (global.json) { - json(redactHomePaths(d, root)); + json(d); return; } diff --git a/src/commands/export.ts b/src/commands/export.ts index b35ef068..b19bfb46 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -41,6 +41,7 @@ export function registerExport(program: Command): void { generatedAt: graph.generatedAt, compact: opts.compact === true ? true : opts.compact === false ? false : undefined, slim: opts.slim === true, + root, }); if (file === '-') { diff --git a/src/core-open/formatters/markdown.ts b/src/core-open/formatters/markdown.ts index e0b2c72e..f976b1dd 100644 --- a/src/core-open/formatters/markdown.ts +++ b/src/core-open/formatters/markdown.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../baseline-suppressions.js'; +import { portableValue } from '../utils/portable-path.js'; import { findingRuleLabel, formatDegradationLines } from '../warnings.js'; -import { redactHomePaths } from '../utils/shareable-path.js'; import { securityPacksLabel } from './text.js'; /** Rows shown before the infrastructure-findings table is cut with an "… N more" line. */ @@ -26,7 +26,7 @@ function markdownDriftCell(score: number | null): string { /** Generate a Markdown report from scan artifact */ export function formatMarkdown(artifact: ScanArtifact, root?: string): string { - artifact = redactHomePaths(artifact, root); + artifact = portableValue(artifact, root); const lines: string[] = []; // Billing (micro-project pricing) is a commercial signal attached by the full diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index f5b0f993..49cd7e19 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; import { baselineSuppressionId } from '../baseline-suppressions.js'; import type { BaselineSuppression, ScanArtifact, Finding, SecurityFinding, SecuritySection, SecuritySeverity } from '../types.js'; -import { redactHomePaths } from '../utils/shareable-path.js'; +import { portableValue } from '../utils/portable-path.js'; /** * SARIF `partialFingerprints` key for every result `vg scan` emits. @@ -24,9 +24,7 @@ const FINDING_FINGERPRINT_KEY = 'vg/finding-id/v1'; * and an empty pack section does not change the document. */ export function formatSarif(artifact: ScanArtifact, root?: string): object { - // Fingerprints and baseline ids use the location, so rewrite home paths - // before either is computed. A second pass sees only relative or `~/` paths. - artifact = redactHomePaths(artifact, root); + artifact = portableValue(artifact, root); const suppressed = suppressionIndex(artifact); const rules = buildRules(artifact.findings); const results = artifact.findings.map((f) => toSarifResult(f, suppressed.get(suppressionKey(f.ruleId, f.location)))); diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index ab5b0247..a6d6ebc5 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -37,7 +37,7 @@ import { formatSarif } from './formatters/sarif.js'; import { formatMarkdown } from './formatters/markdown.js'; import { loadConfig, appendExcludePatterns } from './config.js'; import { pathExists, readJsonFile, writeJsonFile, writeTextFile, ensureDir, FileCache, quickTreeCount } from './utils/fs.js'; -import { redactHomePaths } from './utils/shareable-path.js'; +import { portableValue } from './utils/portable-path.js'; import { assertSafeWalkRoot } from './utils/root-safety.js'; import { detectVcs } from './utils/vcs.js'; import { isCiEnvironment, hasVibgrateWorkflow } from './utils/ci-env.js'; @@ -817,7 +817,7 @@ export async function runCoreScan( // categorisation, not pricing — the commercial rates live server-side. const billing = summarizeBilling(allProjects); - const artifact: ScanArtifact = { + let artifact: ScanArtifact = { schemaVersion: '1.0', timestamp: new Date().toISOString(), vibgrateVersion, @@ -854,8 +854,8 @@ export async function runCoreScan( // Matching findings stay in `findings`. This block is the audit record // in the same document — do not filter them out here. A baseline-only // row is not a current finding and is not listed. - const baselineFindings = Array.isArray(baseline.findings) ? baseline.findings : []; - artifact.baselineComparison = compareBaselineFindings(artifact.findings, baselineFindings); + const baselineFindings = portableValue(Array.isArray(baseline.findings) ? baseline.findings : [], rootDir); + artifact.baselineComparison = compareBaselineFindings(portableValue(artifact.findings, rootDir), baselineFindings); } catch { degradations.push( codedWarning( @@ -875,17 +875,16 @@ export async function runCoreScan( } } - // Machine-readable artifacts are shareable: home prefixes become paths - // relative to the scan root, or `~/…` when the path sits outside it. - // Terminal text keeps the original locations. - const shareable = redactHomePaths(artifact, rootDir); + // JSON, SARIF, and the report share this copy. Home-directory prefixes are + // gone; paths inside the scan root are relative to it. + artifact = portableValue(artifact, rootDir); if (!opts.noLocalArtifacts && !maxPrivacyMode) { const vibgrateDir = path.join(rootDir, '.vibgrate'); await ensureDir(vibgrateDir); - await writeJsonFile(path.join(vibgrateDir, 'scan_result.json'), shareable); - await writeJsonFile(path.join(vibgrateDir, 'solutions.json'), redactHomePaths({ - scannedAt: shareable.timestamp, + await writeJsonFile(path.join(vibgrateDir, 'scan_result.json'), artifact); + await writeJsonFile(path.join(vibgrateDir, 'solutions.json'), portableValue({ + scannedAt: artifact.timestamp, solutions: solutions.map((solution) => ({ solutionId: solution.solutionId, name: solution.name, @@ -911,7 +910,7 @@ export async function runCoreScan( if (!opts.noLocalArtifacts && !maxPrivacyMode) { const projectScores: Record = {}; - for (const project of allProjects) { + for (const project of artifact.projects) { if (project.drift && project.path) { projectScores[project.path] = { projectId: project.projectId, @@ -932,12 +931,12 @@ export async function runCoreScan( if (Object.keys(projectScores).length > 0) { const vibgrateDir = path.join(rootDir, '.vibgrate'); await ensureDir(vibgrateDir); - await writeJsonFile(path.join(vibgrateDir, 'project_scores.json'), redactHomePaths(projectScores, rootDir)); + await writeJsonFile(path.join(vibgrateDir, 'project_scores.json'), portableValue(projectScores, rootDir)); } } if (opts.format === 'json') { - const jsonStr = JSON.stringify(shareable, null, 2); + const jsonStr = JSON.stringify(artifact, null, 2); if (opts.out) { await writeTextFile(path.resolve(opts.out), jsonStr); console.log(chalk.green('✔') + ` JSON written to ${opts.out}`); @@ -945,7 +944,7 @@ export async function runCoreScan( console.log(jsonStr); } } else if (opts.format === 'sarif') { - const sarif = formatSarif(shareable, rootDir); + const sarif = formatSarif(artifact, rootDir); const sarifStr = JSON.stringify(sarif, null, 2); if (opts.out) { await writeTextFile(path.resolve(opts.out), sarifStr); @@ -954,7 +953,7 @@ export async function runCoreScan( console.log(sarifStr); } } else if (opts.format === 'md') { - const markdown = formatMarkdown(shareable, rootDir); + const markdown = formatMarkdown(artifact, rootDir); console.log(markdown); if (opts.out) { await writeTextFile(path.resolve(opts.out), markdown); @@ -997,7 +996,7 @@ export async function runCoreScan( } } - return shareable; + return artifact; } async function buildRepositoryInfo(rootDir: string, remoteUrl: string | undefined, ciSystems: string[] | undefined, nameOverride?: string): Promise { diff --git a/src/core-open/utils/portable-path.ts b/src/core-open/utils/portable-path.ts new file mode 100644 index 00000000..8308cc19 --- /dev/null +++ b/src/core-open/utils/portable-path.ts @@ -0,0 +1,97 @@ +// VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by +// scripts/vendor-core-open.mjs. Do not edit here — change the source package +// and re-run the vendor script. Apache-2.0. +/** + * Paths in machine-readable output. + * + * Default JSON, SARIF, and report output must not carry an absolute home + * directory (`/Users/…` or `/home/…`). A path inside the scan or build root + * becomes root-relative. The same inputs always produce the same relative + * path: no clock, no locale, and no dependence on which account ran the tool. + */ +import path from 'node:path'; + +/** Absolute home prefix, not a URL path segment (`docs/home/…` does not match). */ +const HOME_PATH = /(?|]+/g; + +const HOME_ROOT = /^\/(?:Users|home)\/[^/]+/; + +export function portableValue(value: T, root?: string): T { + if (!containsHomePath(value)) return value; + return rewrite(value, normalizeRoot(root)) as T; +} + +/** Rewrite one string. Exported for tests of a single path. */ +export function portablePath(value: string, root?: string): string { + return stripHomePrefixes(value, normalizeRoot(root)); +} + +function mentionsHome(text: string): boolean { + return text.includes('/Users/') || text.includes('/home/'); +} + +function containsHomePath(value: unknown): boolean { + if (typeof value === 'string') return mentionsHome(value); + if (Array.isArray(value)) return value.some((item) => containsHomePath(item)); + if (!value || typeof value !== 'object') return false; + for (const [key, child] of Object.entries(value as Record)) { + if (mentionsHome(key) || containsHomePath(child)) return true; + } + return false; +} + +function rewrite(value: unknown, root: string | undefined): unknown { + if (typeof value === 'string') return stripHomePrefixes(value, root); + if (Array.isArray(value)) return value.map((item) => rewrite(item, root)); + if (!value || typeof value !== 'object') return value; + const input = value as Record; + const out: Record = {}; + for (const key of Object.keys(input)) { + out[stripHomePrefixes(key, root)] = rewrite(input[key], root); + } + return out; +} + +function stripHomePrefixes(text: string, root: string | undefined): string { + if (!mentionsHome(text)) return text; + HOME_PATH.lastIndex = 0; + return text.replace(HOME_PATH, (match) => { + const { core, trail } = splitTrail(match); + if (!HOME_ROOT.test(core)) return match; + return toRelative(core, root) + trail; + }); +} + +function splitTrail(match: string): { core: string; trail: string } { + const trimmed = /^(.*?)([.,;:)\]}]+)$/.exec(match); + if (!trimmed || !HOME_ROOT.test(trimmed[1]!)) return { core: match, trail: '' }; + return { core: trimmed[1]!, trail: trimmed[2]! }; +} + +function normalizeRoot(root: string | undefined): string | undefined { + if (!root) return undefined; + let norm = root.replace(/\\/g, '/'); + if (norm.length > 1) norm = norm.replace(/\/+$/, ''); + return norm.startsWith('/') ? norm : undefined; +} + +function homeBase(abs: string): string | undefined { + return HOME_ROOT.exec(abs)?.[0]; +} + +function toRelative(absPath: string, root: string | undefined): string { + const abs = path.posix.normalize(absPath); + if (root) { + const base = path.posix.normalize(root); + if (abs === base) return '.'; + if (abs.startsWith(`${base}/`)) return abs.slice(base.length + 1); + if (homeBase(abs) && homeBase(abs) === homeBase(base)) { + const rel = path.posix.relative(base, abs); + if (rel && !/(^|\/)(?:Users|home)\//.test(rel)) return rel; + } + } + const base = homeBase(abs); + if (!base) return absPath; + const rest = abs.slice(base.length).replace(/^\//, ''); + return rest || '.'; +} diff --git a/src/core-open/utils/shareable-path.ts b/src/core-open/utils/shareable-path.ts deleted file mode 100644 index 5b29e67f..00000000 --- a/src/core-open/utils/shareable-path.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Rewrite absolute home-directory paths so machine-readable artifacts can be - * shared. A path under `root` becomes POSIX-relative (`.` for the root - * itself). A path under `/home/`, `/Users/`, or - * `:/Users/` that is outside `root` becomes `~/…`. - * - * This does not call `os.homedir()`. Two machines with the same tree under - * different home prefixes produce the same relative strings. A second pass - * is a no-op: relative paths and `~/…` contain no home prefix. - * - * Strings that are not home paths are left unchanged: relative paths, - * `/test/…`, package names, URLs, and opaque blobs that only contain the - * letters `/home/` in the middle of a token. - */ -import * as path from 'node:path'; - -const HOME_MARK = /\/(?:home|Users)\/|[A-Za-z]:[\\/]Users[\\/]/; - -const USER = String.raw`[A-Za-z0-9._-]{1,64}`; -const SEG = String.raw`(?:\/[A-Za-z0-9._@+-]*)*`; -const CLIMB = String.raw`(?:\.\.\/)+`; -const CLIMB_OPT = String.raw`(?:\.\.\/)*`; -/** Absolute `/home/…`, `/Users/…`, or `C:/Users/…`, optional `../` prefix dropped on rewrite. */ -const ABS = String.raw`(?:\/(?:home|Users)|[A-Za-z]:\/Users)\/${USER}${SEG}`; -/** `../` climb that `path.relative` emits for a home path outside the scan root. */ -const REL_HOME = String.raw`(?:home|Users)\/${USER}${SEG}`; - -const WHOLE = new RegExp(`^(?:(${CLIMB})(${REL_HOME})|(${CLIMB_OPT})(${ABS}))$`); -const EMBEDDED = new RegExp( - `(?:^|(?<=[\\s"'=\`(\\[{/]))(?:(${CLIMB})(${REL_HOME})|(${CLIMB_OPT})(${ABS}))`, - 'g', -); - -const HOME_PREFIX = /^(\/(?:home|Users)\/(?!\.{1,2}(?:\/|$))[^/]+|[A-Za-z]:\/Users\/(?!\.{1,2}(?:\/|$))[^/]+)/; - -function stripTrail(p: string): string { - if (p.length > 1 && p.endsWith('/')) return p.replace(/\/+$/, ''); - return p; -} - -/** POSIX form. A drive letter is kept as written; the rest is `path.posix.normalize`. */ -function canonicalPosix(p: string): string { - const drive = /^([A-Za-z]:)(\/.*)$/.exec(p); - if (drive) { - let rest = path.posix.normalize(drive[2]); - if (!rest.startsWith('/')) rest = `/${rest}`; - return `${drive[1]}${rest}`; - } - return path.posix.normalize(p); -} - -function canonicalRoot(root: string): string { - const posix = root.replace(/\\/g, '/'); - if (posix.startsWith('/') || /^[A-Za-z]:\//.test(posix)) return stripTrail(canonicalPosix(posix)); - return stripTrail(canonicalPosix(path.resolve(root).replace(/\\/g, '/'))); -} - -function rewriteHomePath(abs: string, root?: string): string { - const canon = stripTrail(canonicalPosix(abs)); - const home = HOME_PREFIX.exec(canon); - if (!home) return canon; - if (root) { - const canonRoot = canonicalRoot(root); - if (canon === canonRoot) return '.'; - if (canon.startsWith(`${canonRoot}/`)) return canon.slice(canonRoot.length + 1); - } - if (canon === home[1]) return '~'; - return `~${canon.slice(home[1].length)}`; -} - -function rewriteMatch(relHome: string | undefined, abs: string | undefined, root?: string): string { - if (relHome) return rewriteHomePath(`/${relHome}`, root); - return rewriteHomePath(abs ?? '', root); -} - -/** - * Rewrite one string. `root`, when set, is the scan or build directory: - * home paths inside it become relative to that directory. - */ -export function shareablePath(input: string, root?: string): string { - if (!HOME_MARK.test(input)) return input; - const slash = input.replace(/\\/g, '/'); - let rewritten: string; - if (!/\s/.test(slash)) { - const whole = WHOLE.exec(slash); - if (whole) { - rewritten = rewriteMatch(whole[2], whole[4], root); - return rewritten; - } - } - EMBEDDED.lastIndex = 0; - rewritten = slash.replace(EMBEDDED, (_m, _climb: string, relHome: string, _climbAbs: string, abs: string) => - rewriteMatch(relHome, abs, root), - ); - return rewritten === slash ? input : rewritten; -} - -function isPlainObject(value: object): boolean { - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} - -function walk(value: unknown, root?: string): unknown { - if (typeof value === 'string') return shareablePath(value, root); - if (Array.isArray(value)) return value.map((item) => walk(item, root)); - if (value !== null && typeof value === 'object' && isPlainObject(value)) { - const out: Record = {}; - for (const key of Object.keys(value)) { - out[shareablePath(key, root)] = walk((value as Record)[key], root); - } - return out; - } - return value; -} - -/** - * Deep-copy `value`, rewriting home-directory paths in strings and object keys. - * Key order is preserved. The input is not mutated. - */ -export function redactHomePaths(value: T, root?: string): T { - return walk(value, root) as T; -} - -/** - * Scan root for an artifact file that lives in a `.vibgrate` directory - * (the parent of that directory). Other files have no inferred root, so a - * fixture whose `rootPath` is already a logical path is left unchanged. - */ -export function scanRootFromArtifactFile(artifactPath: string): string | undefined { - const dir = path.dirname(path.resolve(artifactPath)); - if (path.basename(dir) === '.vibgrate') return path.dirname(dir); - return undefined; -} diff --git a/src/engine/artifacts.ts b/src/engine/artifacts.ts index e6f3b558..95a73b37 100644 --- a/src/engine/artifacts.ts +++ b/src/engine/artifacts.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import type { HailePolicy } from './haile/types.js'; import * as path from 'node:path'; +import { portableValue } from '../core-open/utils/portable-path.js'; import { serializeGraph } from './serialize.js'; import { mapFileExists, snapshotPathFor, writeGraphSnapshot } from './snapshot.js'; import { renderReport } from './report.js'; @@ -228,6 +229,7 @@ export function companionArtifactDir(root: string, graphPath: string, env: NodeJ } export function writeArtifacts(graph: VgGraph, options: WriteOptions): WrittenArtifacts { + graph = portableValue(graph, options.root); const graphPath = options.graphPath ?? defaultGraphPath(options.root); const companions = companionArtifactDir(options.root, graphPath); const inRepo = isPathInside(companions, options.root) || isPathInside(graphPath, options.root); @@ -276,7 +278,7 @@ export function writeArtifacts(graph: VgGraph, options: WriteOptions): WrittenAr // never expose a half-written file to a concurrent reader. const tmp = `${graphPath}.${process.pid}.tmp`; try { - fs.writeFileSync(tmp, serializeGraph(graph)); + fs.writeFileSync(tmp, serializeGraph(graph, { root: options.root })); fs.renameSync(tmp, graphPath); } catch (err) { fs.rmSync(tmp, { force: true }); @@ -292,13 +294,13 @@ export function writeArtifacts(graph: VgGraph, options: WriteOptions): WrittenAr if (options.report !== false) { const reportPath = path.join(companions, 'GRAPH_REPORT.md'); - fs.writeFileSync(reportPath, renderReport(graph)); + fs.writeFileSync(reportPath, renderReport(graph, options.root)); written.reportPath = reportPath; } if (options.html !== false) { const htmlPath = path.join(companions, 'graph.html'); - fs.writeFileSync(htmlPath, renderHtml(graph)); + fs.writeFileSync(htmlPath, renderHtml(graph, options.root)); written.htmlPath = htmlPath; } diff --git a/src/engine/export.ts b/src/engine/export.ts index f6ac8d51..96fd6eeb 100644 --- a/src/engine/export.ts +++ b/src/engine/export.ts @@ -1,3 +1,4 @@ +import { portableValue } from '../core-open/utils/portable-path.js'; import { serializeGraph, slimGraphForExport } from './serialize.js'; import { renderReport } from './report.js'; import { renderHtml } from './html.js'; @@ -61,21 +62,23 @@ export interface ExportContext { compact?: boolean; /** Drop area members + grounding for smaller artifacts. */ slim?: boolean; + /** Scan/build root. Paths inside it are written relative to it. */ + root?: string; } export function exportGraph(format: ExportFormat, ctx: ExportContext): string { - const graph = ctx.slim ? slimGraphForExport(ctx.graph) : ctx.graph; + const graph = portableValue(ctx.slim ? slimGraphForExport(ctx.graph) : ctx.graph, ctx.root); const compact = ctx.compact === true || (ctx.compact !== false && format === 'json' && graph.meta.counts.nodes > COMPACT_JSON_NODES); switch (format) { case 'json': - return serializeGraph(graph, { compact }); + return serializeGraph(graph, { compact, root: ctx.root }); case 'md': - return renderReport(graph); + return renderReport(graph, ctx.root); case 'html': - return renderHtml(graph); + return renderHtml(graph, ctx.root); case 'ndjson': return ndjson(graph); case 'graphml': diff --git a/src/engine/html.ts b/src/engine/html.ts index 8847de2f..599f287e 100644 --- a/src/engine/html.ts +++ b/src/engine/html.ts @@ -1,3 +1,4 @@ +import { portableValue } from '../core-open/utils/portable-path.js'; import { stableStringify } from './serialize.js'; import type { VgGraph } from '../schema.js'; @@ -12,7 +13,8 @@ import type { VgGraph } from '../schema.js'; const EMBED_NODE_LIMIT = 5_000; -export function renderHtml(graph: VgGraph): string { +export function renderHtml(graph: VgGraph, root?: string): string { + graph = portableValue(graph, root); const top = graph.nodes .filter((n) => n.kind !== 'file' && n.kind !== 'external') .sort((a, b) => b.importance - a.importance || a.qualifiedName.localeCompare(b.qualifiedName)) diff --git a/src/engine/report.ts b/src/engine/report.ts index f757fd91..e7137c02 100644 --- a/src/engine/report.ts +++ b/src/engine/report.ts @@ -1,3 +1,4 @@ +import { portableValue } from '../core-open/utils/portable-path.js'; import type { VgGraph } from '../schema.js'; /** @@ -5,7 +6,8 @@ import type { VgGraph } from '../schema.js'; * `generatedAt`, the report is byte-stable. Numbers are derived purely from the * graph, so the report can be regenerated from a committed `graph.json`. */ -export function renderReport(graph: VgGraph): string { +export function renderReport(graph: VgGraph, root?: string): string { + graph = portableValue(graph, root); const { meta, provenance, nodes, edges } = graph; const lines: string[] = []; diff --git a/src/engine/serialize.ts b/src/engine/serialize.ts index 72e68dec..8acf3494 100644 --- a/src/engine/serialize.ts +++ b/src/engine/serialize.ts @@ -1,4 +1,5 @@ import { SUPPORTED_SCHEMA_VERSIONS, type SupportedSchemaVersion, type VgGraph } from '../schema.js'; +import { portableValue } from '../core-open/utils/portable-path.js'; import { CliError, ExitCode } from '../util/exit.js'; const REBUILD_HINT = 'Rebuild it with `vg build`.'; @@ -93,10 +94,10 @@ export function unreadableGraphError(): GraphLoadError { * Pretty-printed (2-space) and newline-terminated so the committed artifact is * human-diffable and plays well with the union merge driver. */ -export function serializeGraph(graph: VgGraph, opts?: { compact?: boolean }): string { +export function serializeGraph(graph: VgGraph, opts?: { compact?: boolean; root?: string }): string { // Compact (no pretty-print) for large maps / CI artifacts — still deterministic. const indent = opts?.compact ? 0 : 2; - return `${stableStringify(graph, indent)}\n`; + return `${stableStringify(portableValue(graph, opts?.root), indent)}\n`; } /** diff --git a/src/reporting/commands/report.ts b/src/reporting/commands/report.ts index 3e663342..e353c3ec 100644 --- a/src/reporting/commands/report.ts +++ b/src/reporting/commands/report.ts @@ -2,11 +2,11 @@ import * as path from 'node:path'; import { Command } from 'commander'; import chalk from 'chalk'; import { usageError } from '../../util/exit.js'; -import { redactHomePaths, scanRootFromArtifactFile } from '../../core-open/utils/shareable-path.js'; import { pathExists, readJsonFile } from '../utils/fs.js'; import { formatText } from '../formatters/text.js'; import { formatMarkdown } from '../formatters/markdown.js'; import { formatHtmlReport } from '../formatters/html.js'; +import { portableValue } from '../../core-open/utils/portable-path.js'; import type { ReportFormat, ScanArtifact } from '../types.js'; /** Formats `vg report` accepts. `text` is the default. */ @@ -33,25 +33,20 @@ export const reportCommand = new Command('report') process.exit(1); } - const artifact = await readJsonFile(artifactPath); - // Only a result written under `.vibgrate/` has a known scan root. A file - // passed in from elsewhere keeps its paths unless they are home prefixes, - // which still collapse to `~/…` inside the formatters. - const shareRoot = scanRootFromArtifactFile(artifactPath); - const shareable = redactHomePaths(artifact, shareRoot); + const artifact = portableValue(await readJsonFile(artifactPath)); switch (opts.format) { case 'md': - console.log(formatMarkdown(shareable, shareRoot)); + console.log(formatMarkdown(artifact)); break; case 'json': - console.log(JSON.stringify(shareable, null, 2)); + console.log(JSON.stringify(artifact, null, 2)); break; case 'text': console.log(formatText(artifact)); break; case 'html': - console.log(formatHtmlReport(shareable, shareRoot)); + console.log(formatHtmlReport(artifact)); break; } }); diff --git a/src/reporting/commands/scan.ts b/src/reporting/commands/scan.ts index 83c72556..f23006a0 100644 --- a/src/reporting/commands/scan.ts +++ b/src/reporting/commands/scan.ts @@ -841,7 +841,6 @@ export const scanCommand = new Command('scan') findings: artifact.findings, driftGate: failOn.legacy === 'warn' || failOn.legacy === 'error' ? failOn.legacy : undefined, extraSuites: junitSuites, - root: rootDir, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -865,7 +864,7 @@ export const scanCommand = new Command('scan') if (found === null) { junitSuites.push(architectureGateSuite({ status: 'blocked', message: ARCHITECTURE_NOT_CLASSIFIED })); } else if (found.rows.length > 0) { - junitSuites.push(architectureGateSuite({ status: 'failed', rows: found.rows }, rootDir)); + junitSuites.push(architectureGateSuite({ status: 'failed', rows: found.rows })); } else { junitSuites.push(architectureGateSuite({ status: 'clean' })); } @@ -889,7 +888,7 @@ export const scanCommand = new Command('scan') status: 'evaluated', findings: section.findings, failingIds: outcome.matched.map((f) => f.id), - }, rootDir)); + })); } } } diff --git a/src/reporting/formatters/html.ts b/src/reporting/formatters/html.ts index 4df6bef5..e1c98703 100644 --- a/src/reporting/formatters/html.ts +++ b/src/reporting/formatters/html.ts @@ -1,6 +1,5 @@ import { fixAvailableHint } from '../../core-open/formatters/fix-hint.js'; import { redactForDisplay } from '../../core-open/utils/redact.js'; -import { redactHomePaths } from '../../core-open/utils/shareable-path.js'; import type { SecurityFinding } from '../../core-open/types.js'; import type { Finding, ScanArtifact } from '../types.js'; @@ -8,8 +7,7 @@ import type { Finding, ScanArtifact } from '../types.js'; * Self-contained HTML for `vg report --format html`. * * Pure function of the artifact: stable sorts, no clock, no randomness, no - * network, no external assets. Home-directory paths are rewritten to a - * repo-relative or `~/` form, then artifact strings are secret-redacted and escaped. + * network, no external assets. Artifact strings are redacted, then escaped. * * Stable ids: `#summary`, `#vulnerabilities`, `#security`, `#findings`. * @@ -44,8 +42,7 @@ const VULN_RANK: Record = { const LEVEL_RANK: Record = { error: 0, warning: 1, note: 2 }; -export function formatHtmlReport(artifact: ScanArtifact, root?: string): string { - artifact = redactHomePaths(artifact, root); +export function formatHtmlReport(artifact: ScanArtifact): string { const findings = Array.isArray(artifact.findings) ? artifact.findings : []; const vulnFindings = findings.filter((f) => f.ruleId === VULN_RULE_ID); const otherFindings = findings.filter((f) => f.ruleId !== VULN_RULE_ID); diff --git a/src/reporting/formatters/markdown.ts b/src/reporting/formatters/markdown.ts index 5c1c907e..dcee7706 100644 --- a/src/reporting/formatters/markdown.ts +++ b/src/reporting/formatters/markdown.ts @@ -1,7 +1,7 @@ import type { ScanArtifact } from '../types.js'; import { baselineSuppressionLabel } from '../../core-open/baseline-suppressions.js'; +import { portableValue } from '../../core-open/utils/portable-path.js'; import { findingRuleLabel, formatDegradationLines } from '../../core-open/warnings.js'; -import { redactHomePaths } from '../../core-open/utils/shareable-path.js'; /** A measured zero stays `0`. An unmeasured component is `n/a`, never `0`. */ function markdownDriftCell(score: number | null): string { @@ -10,7 +10,7 @@ function markdownDriftCell(score: number | null): string { /** Generate a Markdown report from scan artifact */ export function formatMarkdown(artifact: ScanArtifact, root?: string): string { - artifact = redactHomePaths(artifact, root); + artifact = portableValue(artifact, root); const lines: string[] = []; lines.push('# Vibgrate Drift Report'); diff --git a/src/reporting/junit-report.ts b/src/reporting/junit-report.ts index 65140293..e88f6905 100644 --- a/src/reporting/junit-report.ts +++ b/src/reporting/junit-report.ts @@ -4,9 +4,7 @@ * The report summarizes drift findings and the gates `vg scan` actually * judges (drift `--fail-on`, architecture, security packs, drift budget). * It is a pure function of that input: no clock, no host name, no DSN, no - * repository URL. Home-directory paths are rewritten the same way as the - * JSON artifact (relative to the scan root, or `~/…`). The optional JUnit - * `timestamp` attribute is omitted; + * repository URL. The optional JUnit `timestamp` attribute is omitted; * `time` is always `0` because wall-clock duration is not part of the result. * * Suites, in order: @@ -21,7 +19,6 @@ * drift gate; otherwise it is ``. Notes never fail a gate. */ import * as path from 'node:path'; -import { shareablePath } from '../core-open/utils/shareable-path.js'; import { compareDriftBudget, type DriftBudgetGateResult } from './drift-budget-gate.js'; import { writeTextFile } from './utils/fs.js'; @@ -76,8 +73,6 @@ export interface ScanJUnitInput { driftGate?: 'warn' | 'error'; /** Appended after the findings suite, in the order given. Empty suites are dropped. */ extraSuites?: readonly JUnitSuite[]; - /** Scan directory. Home paths inside it are written relative to this directory. */ - root?: string; } export interface ArchitectureJUnitRow { @@ -151,16 +146,8 @@ function findingBody(f: JUnitFinding): string { return `${f.message}\nrule: ${f.ruleId}\nlocation: ${f.location}\nlevel: ${f.level}`; } -export function findingsSuite( - findings: readonly JUnitFinding[], - driftGate?: 'warn' | 'error', - root?: string, -): JUnitSuite { - const sorted = findings.map((f) => ({ - ...f, - location: shareablePath(f.location, root), - message: shareablePath(f.message, root), - })).sort(compareFindings); +export function findingsSuite(findings: readonly JUnitFinding[], driftGate?: 'warn' | 'error'): JUnitSuite { + const sorted = [...findings].sort(compareFindings); if (sorted.length === 0) { return { name: 'findings', cases: [pass(JUNIT_FINDINGS_CLASS, 'no findings')] }; } @@ -183,22 +170,17 @@ export function architectureGateSuite( | { status: 'blocked'; message: string } | { status: 'clean' } | { status: 'failed'; rows: readonly ArchitectureJUnitRow[] }, - root?: string, ): JUnitSuite { if (input.status === 'blocked') { return { name: 'architecture', - cases: [failure(JUNIT_ARCHITECTURE_CLASS, 'architecture', 'gate', shareablePath(input.message, root))], + cases: [failure(JUNIT_ARCHITECTURE_CLASS, 'architecture', 'gate', input.message)], }; } if (input.status === 'clean') { return { name: 'architecture', cases: [pass(JUNIT_ARCHITECTURE_CLASS, 'architecture')] }; } - const rows = input.rows.map((r) => ({ - ...r, - file: shareablePath(r.file, root), - message: shareablePath(r.message, root), - })).sort((a, b) => + const rows = [...input.rows].sort((a, b) => cmp(a.file, b.file) || (a.line ?? 0) - (b.line ?? 0) || cmp(a.symbol, b.symbol) @@ -228,20 +210,15 @@ export function securityGateSuite( input: | { status: 'blocked'; message: string } | { status: 'evaluated'; findings: readonly SecurityJUnitFinding[]; failingIds: readonly string[] }, - root?: string, ): JUnitSuite { if (input.status === 'blocked') { return { name: 'security', - cases: [failure(JUNIT_SECURITY_CLASS, 'security', 'gate', shareablePath(input.message, root))], + cases: [failure(JUNIT_SECURITY_CLASS, 'security', 'gate', input.message)], }; } const failing = new Set(input.failingIds); - const findings = input.findings.map((f) => ({ - ...f, - path: shareablePath(f.path, root), - message: shareablePath(f.message, root), - })).sort((a, b) => + const findings = [...input.findings].sort((a, b) => cmp(a.path, b.path) || (a.line ?? 0) - (b.line ?? 0) || cmp(a.rule, b.rule) @@ -334,7 +311,7 @@ export function configDriftBudgetCases(gate: DriftBudgetGateResult): JUnitTestCa } export function buildScanJUnitReport(input: ScanJUnitInput): JUnitReport { - const suites: JUnitSuite[] = [findingsSuite(input.findings, input.driftGate, input.root)]; + const suites: JUnitSuite[] = [findingsSuite(input.findings, input.driftGate)]; for (const suite of input.extraSuites ?? []) { if (suite.cases.length > 0) suites.push(suite); } diff --git a/test/portable-output-paths.test.ts b/test/portable-output-paths.test.ts new file mode 100644 index 00000000..48b33325 --- /dev/null +++ b/test/portable-output-paths.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { formatMarkdown as formatScanMarkdown } from '../src/core-open/formatters/markdown.js'; +import { formatSarif } from '../src/core-open/formatters/sarif.js'; +import type { ScanArtifact } from '../src/core-open/types.js'; +import { portableValue } from '../src/core-open/utils/portable-path.js'; +import { renderReport } from '../src/engine/report.js'; +import { serializeGraph } from '../src/engine/serialize.js'; +import { formatMarkdown as formatReport } from '../src/reporting/formatters/markdown.js'; +import type { VgGraph } from '../src/schema.js'; + +/** + * Fixture paths use both home prefixes. Default machine-readable output must + * not repeat them: paths inside the scan/build root are relative to that root, + * and the same relative layout is byte-identical under either prefix. + */ +const ROOT = '/home/dev/fixture'; + +function scanFixture(): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp: '2026-10-09T00:00:00.000Z', + vibgrateVersion: '0.0.0', + rootPath: 'fixture', + projects: [ + { + type: 'node', + path: '/home/dev/fixture/services/api', + name: 'api', + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }, + ], + drift: { + score: 10, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 10, eolScore: 0 }, + }, + findings: [ + { + ruleId: 'vibgrate/dependency-rot', + level: 'warning', + message: 'see /home/dev/fixture/services/api/package.json and /Users/dev/fixture/pkg/lib.ts', + location: '/home/dev/fixture/services/api/package.json', + }, + ], + repository: { name: 'fixture', remoteUrl: 'https://example.com/home/docs' }, + }; +} + +function graphNode(file: string): VgGraph['nodes'][number] { + return { + id: 'fn-main', + kind: 'function', + name: 'main', + qualifiedName: 'main', + file, + span: { start: 1, end: 4 }, + lang: 'ts', + importance: 1, + centrality: { degree: 1, pagerank: 0, betweenness: 0, eigenvector: 0 }, + area: -1, + isHub: false, + tested: null, + }; +} + +function graphFixture(file: string): VgGraph { + return { + schemaVersion: 'vg-graph/1.1', + generatedAt: '2026-10-09T00:00:00.000Z', + provenance: { + tool: 'vg', + version: '0.0.0', + grammars: { ts: 'test' }, + resolver: ['heuristic'], + deep: false, + corpusHash: 'abc', + }, + meta: { + root: '.', + languages: ['ts'], + counts: { nodes: 1, edges: 0, areas: 0, tests: 0, untested: 0 }, + cluster: 'none', + edgeKinds: [], + }, + nodes: [graphNode(file)], + edges: [], + areas: [], + }; +} + +function assertNoHomePrefix(label: string, text: string): void { + expect(text, label).not.toContain('/home/dev/'); + expect(text, label).not.toContain('/home/ada/'); + expect(text, label).not.toContain('/Users/'); +} + +describe('default machine-readable output paths', () => { + it('omits home-prefixed fixture paths from json, sarif, and report', () => { + const artifact = scanFixture(); + const json = JSON.stringify(portableValue(artifact, ROOT), null, 2); + const sarif = JSON.stringify(formatSarif(artifact, ROOT), null, 2); + const scanReport = formatScanMarkdown(artifact, ROOT); + const report = formatReport(artifact, ROOT); + const graphJson = serializeGraph(graphFixture('/home/dev/fixture/src/main.ts'), { root: ROOT }); + const graphReport = renderReport(graphFixture('/Users/dev/fixture/src/main.ts'), '/Users/dev/fixture'); + + for (const [label, text] of [ + ['json', json], + ['sarif', sarif], + ['scan report', scanReport], + ['report', report], + ['graph json', graphJson], + ['graph report', graphReport], + ] as const) { + assertNoHomePrefix(label, text); + } + + expect(json).toContain('services/api/package.json'); + expect(json).toContain('https://example.com/home/docs'); + expect(sarif).toContain('"uri": "services/api/package.json"'); + expect(report).toContain('services/api/package.json'); + expect(graphJson).toContain('src/main.ts'); + expect(graphReport).toContain('src/main.ts'); + + expect(JSON.stringify(formatSarif(scanFixture(), ROOT), null, 2)).toBe(sarif); + expect(formatReport(scanFixture(), ROOT)).toBe(report); + expect(serializeGraph(graphFixture('/home/dev/fixture/src/main.ts'), { root: ROOT })).toBe(graphJson); + expect(serializeGraph(graphFixture('/home/ada/app/src/main.ts'), { root: '/home/ada/app' })).toBe( + serializeGraph(graphFixture('/Users/ada/app/src/main.ts'), { root: '/Users/ada/app' }), + ); + expect(portableValue(portableValue(artifact, ROOT), ROOT)).toEqual(portableValue(artifact, ROOT)); + }); +}); diff --git a/test/shareable-paths.test.ts b/test/shareable-paths.test.ts deleted file mode 100644 index 0521a6d2..00000000 --- a/test/shareable-paths.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { formatMarkdown as formatMarkdownCore } from '../src/core-open/formatters/markdown.js'; -import { formatSarif } from '../src/core-open/formatters/sarif.js'; -import type { Finding, ScanArtifact } from '../src/core-open/types.js'; -import { redactHomePaths, scanRootFromArtifactFile, shareablePath } from '../src/core-open/utils/shareable-path.js'; -import { formatHtmlReport } from '../src/reporting/formatters/html.js'; -import { formatMarkdown } from '../src/reporting/formatters/markdown.js'; -import { findingsSuite, renderJUnitReport, securityGateSuite } from '../src/reporting/junit-report.js'; - -/** - * One tree, two machines. `root` is the scan directory. `cache` and `climbed` - * are the same files outside that directory, written the way each machine - * would spell them. - */ -function artifactAt(root: string, cache: string, climbed: string): ScanArtifact { - return { - schemaVersion: '1.0', - timestamp: '2026-01-01T00:00:00.000Z', - vibgrateVersion: '0.0.0-test', - rootPath: root, - projects: [ - { - type: 'node', - path: root, - name: 'app', - frameworks: [], - dependencies: [], - dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, - }, - ], - drift: { - score: 10, - riskLevel: 'low', - components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, - }, - findings: [ - finding(`${root}/package.json`, `lockfile ${root}/package.json is old`), - finding(cache, 'cache lives outside the scan root'), - finding(climbed, 'climbed out of the scan root'), - ], - extended: { - security: { - schema: 'vg.security.v1', - engine: 'test', - packs: { iac: '1' }, - facts: { received: 1, evaluated: 1, rejected: 0 }, - findings: [ - { - id: 'a'.repeat(32), - pack: 'iac', - packVersion: '1', - rule: 'public-bucket', - severity: 'high', - message: `open bucket in ${root}/infra/main.tf`, - path: `${root}/infra/main.tf`, - }, - ], - }, - }, - }; -} - -function finding(location: string, message: string): Finding { - return { ruleId: 'vibgrate/runtime-lag', level: 'warning', message, location }; -} - -const DEV = artifactAt( - '/home/dev/app', - '/home/dev/.cache/vibgrate/cas', - '../../../home/dev/.local/state/graph.json', -); -const CASEY = artifactAt( - '/Users/casey/app', - '/Users/casey/.cache/vibgrate/cas', - '../../../Users/casey/.local/state/graph.json', -); - -function machineReadable(scan: ScanArtifact, root: string): string { - const json = JSON.stringify(redactHomePaths(scan, root)); - const sarif = JSON.stringify(formatSarif(scan, root)); - const md = `${formatMarkdownCore(scan, root)}\n${formatMarkdown(scan, root)}`; - const html = formatHtmlReport(scan, root); - const junit = renderJUnitReport({ - name: 'vg scan', - suites: [ - findingsSuite(scan.findings, 'warn', root), - securityGateSuite({ - status: 'evaluated', - findings: scan.extended!.security!.findings, - failingIds: [scan.extended!.security!.findings[0]!.id], - }, root), - ], - }); - return [json, sarif, md, html, junit].join('\n'); -} - -describe('shareable home paths', () => { - it('drops home prefixes from default machine-readable output', () => { - const text = machineReadable(DEV, '/home/dev/app') + machineReadable(CASEY, '/Users/casey/app'); - expect(text).not.toContain('/home/dev'); - expect(text).not.toContain('/Users/casey'); - expect(text).not.toContain('../../../home/'); - expect(text).not.toContain('../../../Users/'); - - const shareable = redactHomePaths(DEV, '/home/dev/app'); - expect(shareable.rootPath).toBe('.'); - expect(shareable.projects[0]?.path).toBe('.'); - expect(shareable.findings.map((f) => f.location)).toEqual([ - 'package.json', - '~/.cache/vibgrate/cas', - '~/.local/state/graph.json', - ]); - expect(shareable.extended?.security?.findings[0]?.path).toBe('infra/main.tf'); - expect(shareable.findings[0]?.message).toBe('lockfile package.json is old'); - expect(DEV.findings[0]?.location).toBe('/home/dev/app/package.json'); - }); - - it('produces the same relative artifact from two home prefixes', () => { - const alice = artifactAt( - '/home/alice/work', - '/home/alice/.cache/vibgrate/cas', - '../../../home/alice/.local/state/graph.json', - ); - const bob = artifactAt( - '/Users/bob/work', - '/Users/bob/.cache/vibgrate/cas', - '../../../Users/bob/.local/state/graph.json', - ); - expect(redactHomePaths(alice, '/home/alice/work')).toEqual(redactHomePaths(bob, '/Users/bob/work')); - expect(formatSarif(alice, '/home/alice/work')).toEqual(formatSarif(bob, '/Users/bob/work')); - const once = redactHomePaths(alice, '/home/alice/work'); - expect(redactHomePaths(once, '/home/alice/work')).toEqual(once); - }); - - it('leaves non-home paths, URLs, and opaque tokens unchanged', () => { - expect(shareablePath('/test/app')).toBe('/test/app'); - expect(shareablePath('/fixture')).toBe('/fixture'); - expect(shareablePath('https://github.com/home/dev/app')).toBe('https://github.com/home/dev/app'); - expect(shareablePath('AAAA/home/abcd/BBBB')).toBe('AAAA/home/abcd/BBBB'); - expect(shareablePath('/home/../etc/passwd')).toBe('/etc/passwd'); - expect(shareablePath('package.json', '/home/dev/app')).toBe('package.json'); - expect(shareablePath('see https://github.com/home/dev/app and /home/dev/app/package.json', '/home/dev/app')) - .toBe('see https://github.com/home/dev/app and package.json'); - - const kept = artifactAt('/test/app', '/test/cache', 'notes'); - kept.findings = [finding('/test/app', 'kept')]; - kept.extended = undefined; - const sarif = JSON.stringify(formatSarif(kept)); - expect(sarif).toContain('/test/app'); - }); - - it('rewrites Windows home paths and paths that only share a prefix', () => { - expect(shareablePath('C:\\Users\\casey\\app\\src\\a.ts', 'C:/Users/casey/app')).toBe('src/a.ts'); - expect(shareablePath('/home/dev/app/src/c++/main.cpp', '/home/dev/app')).toBe('src/c++/main.cpp'); - expect(shareablePath('/home/dev/app-extra/file.ts', '/home/dev/app')).toBe('~/app-extra/file.ts'); - expect(shareablePath('/home/dev', '/home/dev/app')).toBe('~'); - }); - - it('infers a scan root only for artifacts stored under .vibgrate', () => { - expect(scanRootFromArtifactFile('/home/dev/app/.vibgrate/scan_result.json')).toBe('/home/dev/app'); - expect(scanRootFromArtifactFile('/tmp/scan_result.json')).toBeUndefined(); - }); - - it('rewrites build and doctor JSON fields', () => { - const built = redactHomePaths({ - cas: { dir: '/home/dev/.cache/vibgrate/cas' }, - artifacts: { graphPath: '/home/dev/app/.vibgrate/graph.json', reportPath: '/home/dev/app/.vibgrate/report.md' }, - }, '/home/dev/app'); - expect(built.cas.dir).toBe('~/.cache/vibgrate/cas'); - expect(built.artifacts.graphPath).toBe('.vibgrate/graph.json'); - expect(built.artifacts.reportPath).toBe('.vibgrate/report.md'); - - const diagnosis = redactHomePaths({ - root: '/home/dev/app', - map: { path: '/home/dev/.local/state/vibgrate/graph.json' }, - }, '/home/dev/app'); - expect(diagnosis.root).toBe('.'); - expect(diagnosis.map.path).toBe('~/.local/state/vibgrate/graph.json'); - }); -});