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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ backward compatible.

### Changed

- **Machine-readable output drops absolute home-directory paths (#282).**
JSON, SARIF, and reports rewrite `/Users/<user>/` and `/home/<user>/`
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
Expand Down
1 change: 1 addition & 0 deletions src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === '-') {
Expand Down
4 changes: 3 additions & 1 deletion src/core-open/formatters/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// 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 { securityPacksLabel } from './text.js';

Expand All @@ -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 = portableValue(artifact, root);
const lines: string[] = [];

// Billing (micro-project pricing) is a commercial signal attached by the full
Expand Down
4 changes: 3 additions & 1 deletion src/core-open/formatters/sarif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { portableValue } from '../utils/portable-path.js';

/**
* SARIF `partialFingerprints` key for every result `vg scan` emits.
Expand All @@ -22,7 +23,8 @@ 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 {
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))));
Expand Down
23 changes: 14 additions & 9 deletions src/core-open/run-core-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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';
Expand Down Expand Up @@ -816,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,
Expand Down Expand Up @@ -853,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(
Expand All @@ -874,11 +875,15 @@ export async function runCoreScan(
}
}

// 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'), artifact);
await writeJsonFile(path.join(vibgrateDir, 'solutions.json'), {
await writeJsonFile(path.join(vibgrateDir, 'solutions.json'), portableValue({
scannedAt: artifact.timestamp,
solutions: solutions.map((solution) => ({
solutionId: solution.solutionId,
Expand All @@ -887,7 +892,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
Expand All @@ -905,7 +910,7 @@ export async function runCoreScan(

if (!opts.noLocalArtifacts && !maxPrivacyMode) {
const projectScores: Record<string, object> = {};
for (const project of allProjects) {
for (const project of artifact.projects) {
if (project.drift && project.path) {
projectScores[project.path] = {
projectId: project.projectId,
Expand All @@ -926,7 +931,7 @@ 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'), portableValue(projectScores, rootDir));
}
}

Expand All @@ -939,7 +944,7 @@ export async function runCoreScan(
console.log(jsonStr);
}
} else if (opts.format === 'sarif') {
const sarif = formatSarif(artifact);
const sarif = formatSarif(artifact, rootDir);
const sarifStr = JSON.stringify(sarif, null, 2);
if (opts.out) {
await writeTextFile(path.resolve(opts.out), sarifStr);
Expand All @@ -948,7 +953,7 @@ export async function runCoreScan(
console.log(sarifStr);
}
} else if (opts.format === 'md') {
const markdown = formatMarkdown(artifact);
const markdown = formatMarkdown(artifact, rootDir);
console.log(markdown);
if (opts.out) {
await writeTextFile(path.resolve(opts.out), markdown);
Expand Down
97 changes: 97 additions & 0 deletions src/core-open/utils/portable-path.ts
Original file line number Diff line number Diff line change
@@ -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 = /(?<![\w:])\/(?:Users|home)\/[^\s"'`<>|]+/g;

const HOME_ROOT = /^\/(?:Users|home)\/[^/]+/;

export function portableValue<T>(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<string, unknown>)) {
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<string, unknown>;
const out: Record<string, unknown> = {};
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 || '.';
}
8 changes: 5 additions & 3 deletions src/engine/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand All @@ -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;
}

Expand Down
11 changes: 7 additions & 4 deletions src/engine/export.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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':
Expand Down
4 changes: 3 additions & 1 deletion src/engine/html.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { portableValue } from '../core-open/utils/portable-path.js';
import { stableStringify } from './serialize.js';
import type { VgGraph } from '../schema.js';

Expand All @@ -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))
Expand Down
4 changes: 3 additions & 1 deletion src/engine/report.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { portableValue } from '../core-open/utils/portable-path.js';
import type { VgGraph } from '../schema.js';

/**
* Deterministic Markdown summary (`GRAPH_REPORT.md`). Given a pinned
* `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[] = [];

Expand Down
5 changes: 3 additions & 2 deletions src/engine/serialize.ts
Original file line number Diff line number Diff line change
@@ -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`.';
Expand Down Expand Up @@ -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`;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/reporting/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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. */
Expand All @@ -32,7 +33,7 @@ export const reportCommand = new Command('report')
process.exit(1);
}

const artifact = await readJsonFile<ScanArtifact>(artifactPath);
const artifact = portableValue(await readJsonFile<ScanArtifact>(artifactPath));

switch (opts.format) {
case 'md':
Expand Down
Loading
Loading