@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Cross-repository lineage declaration (Finding Provenance PRD, M4 §4.2).
|
|
2
|
+
//
|
|
3
|
+
// Git has no native cross-repo history — this is an OPERATOR-DECLARED link,
|
|
4
|
+
// read from .agentic-security/repo-lineage.json:
|
|
5
|
+
// { "linkedFrom": { "path": "../old-repo-clone", "atCommit": "<sha>" } }
|
|
6
|
+
//
|
|
7
|
+
// Scoped to LOCAL clones only — no remote fetch, matching this codebase's
|
|
8
|
+
// "no runtime cloud calls" convention. Never throws; a missing, malformed,
|
|
9
|
+
// or unreachable link degrades to null, which origin-resolver.js reads as
|
|
10
|
+
// "no lineage available" and proceeds exactly as it did before M4.
|
|
11
|
+
//
|
|
12
|
+
// NOTE on git-evidence.js reuse (checked as of M3's final fix round, and
|
|
13
|
+
// re-checked against the file's FULL export surface after a review caught
|
|
14
|
+
// an incomplete first pass): `_isSha` and its `GIT_TIMEOUT_MS` constant are
|
|
15
|
+
// NOT exported there (both are module-local), and the two exported
|
|
16
|
+
// low-level helpers (`_relPath`, `_isSafeRevision`) genuinely don't fit —
|
|
17
|
+
// `_isSafeRevision` intentionally accepts ref/branch names, looser than the
|
|
18
|
+
// full-SHA-only check `atCommit` needs, and `_relPath` resolves paths
|
|
19
|
+
// relative to `scanRoot`, which is the wrong side of this module's job
|
|
20
|
+
// (validating a path OUTSIDE scanRoot). BUT two of the file's HIGHER-level
|
|
21
|
+
// exports are directly reusable and are used below instead of being
|
|
22
|
+
// reimplemented: `isGitRepo(scanRoot)` treats its argument purely as a cwd
|
|
23
|
+
// for `git rev-parse --git-dir`, with no assumption that cwd is "the"
|
|
24
|
+
// scanned repo, so it works unchanged on the linked repo's path; and
|
|
25
|
+
// `commitMeta(scanRoot, sha)` already runs `atCommit` through the file's own
|
|
26
|
+
// hardened `_isSha` guard before ever invoking `git show`, and returns
|
|
27
|
+
// `null` on both an invalid shape and a nonexistent commit — exactly what
|
|
28
|
+
// this module's existence check needs, with real commit metadata as a
|
|
29
|
+
// bonus. See the M3 §3.3 finding in missing-control-resolver.js's history
|
|
30
|
+
// for what skipping this check cost there.
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import { isGitRepo, commitMeta } from './git-evidence.js';
|
|
34
|
+
import { statePath } from '../state-dir.js';
|
|
35
|
+
|
|
36
|
+
// Kept as a local pre-check, deliberately NOT dropped even though
|
|
37
|
+
// `commitMeta` re-validates internally: this regex must reject an
|
|
38
|
+
// unsafe-looking `atCommit` (e.g. `--upload-pack=evil`) BEFORE the value is
|
|
39
|
+
// even handed to `commitMeta`/`isGitRepo`, so the argument-injection defense
|
|
40
|
+
// fires as the first line of defense rather than relying solely on a
|
|
41
|
+
// downstream module's own guard.
|
|
42
|
+
const SHA_RE = /^[0-9a-f]{4,40}$/i;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns { path: <absolute, verified-reachable local git repo>, atCommit: <verified-existing sha> }
|
|
46
|
+
* or null on ANY problem — missing config, malformed JSON, missing fields,
|
|
47
|
+
* an unsafe-looking atCommit, a path that isn't a real local git repo, or a
|
|
48
|
+
* commit that doesn't exist there. This function's whole job is to hand
|
|
49
|
+
* back either a fully-verified link or nothing; it never hands back a
|
|
50
|
+
* half-verified one for a caller to trust blindly.
|
|
51
|
+
*/
|
|
52
|
+
export function loadRepoLineage(scanRoot) {
|
|
53
|
+
const configPath = statePath(scanRoot, 'repo-lineage.json');
|
|
54
|
+
let text;
|
|
55
|
+
try { text = fs.readFileSync(configPath, 'utf8'); } catch { return null; }
|
|
56
|
+
let doc;
|
|
57
|
+
try { doc = JSON.parse(text); } catch { return null; }
|
|
58
|
+
const linked = doc?.linkedFrom;
|
|
59
|
+
if (!linked || typeof linked !== 'object') return null;
|
|
60
|
+
const { path: relOrAbsPath, atCommit } = linked;
|
|
61
|
+
if (typeof relOrAbsPath !== 'string' || !relOrAbsPath) return null;
|
|
62
|
+
if (typeof atCommit !== 'string' || !SHA_RE.test(atCommit)) return null;
|
|
63
|
+
|
|
64
|
+
const absPath = path.isAbsolute(relOrAbsPath) ? relOrAbsPath : path.resolve(scanRoot, relOrAbsPath);
|
|
65
|
+
// No separate fs.statSync/isDirectory pre-check: isGitRepo's underlying
|
|
66
|
+
// `git rev-parse --git-dir` call already degrades to false (not a throw)
|
|
67
|
+
// when `absPath` doesn't exist at all — verified directly rather than
|
|
68
|
+
// assumed — so a second existence check here would just be the same
|
|
69
|
+
// reimplementation-avoidance lesson applied halfway.
|
|
70
|
+
if (!isGitRepo(absPath)) return null;
|
|
71
|
+
if (commitMeta(absPath, atCommit) === null) return null;
|
|
72
|
+
|
|
73
|
+
return { path: absPath, atCommit };
|
|
74
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import { candidateCommitsForFile, getFirstParent, getBlobAtCommit, commitMeta } from './git-evidence.js';
|
|
3
|
+
import { PROVENANCE_METHOD } from './schema.js';
|
|
4
|
+
|
|
5
|
+
function parseSemver(v) {
|
|
6
|
+
const m = String(v || '').replace(/^[^\d]*/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
7
|
+
if (!m) return null;
|
|
8
|
+
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function cmpSemver(a, b) {
|
|
12
|
+
for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] - b[i]; }
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function versionInRange(version, range) {
|
|
17
|
+
const v = parseSemver(version);
|
|
18
|
+
if (!v) return false;
|
|
19
|
+
if (range.introduced) {
|
|
20
|
+
const lo = parseSemver(range.introduced);
|
|
21
|
+
if (lo && cmpSemver(v, lo) < 0) return false;
|
|
22
|
+
}
|
|
23
|
+
if (range.fixed) {
|
|
24
|
+
const hi = parseSemver(range.fixed);
|
|
25
|
+
if (hi && cmpSemver(v, hi) >= 0) return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function scaStableId(entry) {
|
|
31
|
+
const material = `${entry.osvId || ''}|${entry.name || ''}|${entry.ecosystem || ''}|${(entry.filePath || '').split('/').slice(-2).join('/')}`;
|
|
32
|
+
return crypto.createHash('sha256').update(material).digest('hex').slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractDeclaredVersion(blobText, depName, filePath) {
|
|
36
|
+
const base = filePath.split('/').pop();
|
|
37
|
+
if (base === 'package.json') {
|
|
38
|
+
try {
|
|
39
|
+
const d = JSON.parse(blobText);
|
|
40
|
+
return (d.dependencies && d.dependencies[depName]) || (d.devDependencies && d.devDependencies[depName]) || null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (/^requirements(?:[._-][\w.-]+)?\.txt$/i.test(base)) {
|
|
46
|
+
for (const line of blobText.split('\n')) {
|
|
47
|
+
const m = line.trim().match(/^([A-Za-z0-9_.-]+)\s*[=~<>!]+\s*([^\s;#,]*)/);
|
|
48
|
+
if (m && m[1].toLowerCase() === depName.toLowerCase()) return m[2];
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function originResult({ meta, commitsConsidered, absentInParents, parentBoundaryVerified }) {
|
|
56
|
+
return {
|
|
57
|
+
status: 'complete', method: PROVENANCE_METHOD.DEPENDENCY_GRAPH_DIFF, commitsConsidered,
|
|
58
|
+
findingOrigin: { commit: meta.commit, authorName: meta.authorName, authorEmail: meta.authorEmail,
|
|
59
|
+
authorDate: meta.authorDate, committerDate: meta.committerDate, summary: meta.summary,
|
|
60
|
+
presentInCommit: true, absentInParents },
|
|
61
|
+
parentBoundaryVerified,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function resolveDirectSCAOrigin(scanRoot, scaEntry, { since, deadlineAt } = {}) {
|
|
66
|
+
const file = scaEntry.filePath;
|
|
67
|
+
if (!file) return { status: 'not_available', reason: 'no-manifest-path', commitsConsidered: 0 };
|
|
68
|
+
|
|
69
|
+
const candidates = candidateCommitsForFile(scanRoot, file, { since });
|
|
70
|
+
if (candidates.length === 0) return { status: 'not_available', reason: 'no-candidate-commits', commitsConsidered: 0 };
|
|
71
|
+
|
|
72
|
+
const range = { introduced: null, fixed: (scaEntry.fixedVersions || [])[0] || null };
|
|
73
|
+
let commitsConsidered = 0;
|
|
74
|
+
// With no `introduced` lower bound, `versionInRange` treats any version below
|
|
75
|
+
// `fixed` as "in range" (by design — see the versionInRange unit test: a
|
|
76
|
+
// fixed-only range means "vulnerable since inception"). That makes two
|
|
77
|
+
// distinct real-world situations indistinguishable by pure range membership:
|
|
78
|
+
// (1) a genuinely safer earlier version that nonetheless also falls below
|
|
79
|
+
// `fixed`, later bumped into the actually-vulnerable value, vs. (2) a
|
|
80
|
+
// routine patch-level bump (e.g. a dependabot PR) that moves the declared
|
|
81
|
+
// version WITHIN an already-vulnerable window. Both look identical as
|
|
82
|
+
// "parent in range, child in range, version string differs" — there is no
|
|
83
|
+
// way to tell them apart without an introduced bound. So a parent whose
|
|
84
|
+
// version is ALSO in-range is never treated as a confirmed transition (that
|
|
85
|
+
// would misattribute case 2 to the bump commit — a Critical bug caught in
|
|
86
|
+
// review: reporting `parentBoundaryVerified:true` at a routine patch bump
|
|
87
|
+
// when the dependency was vulnerable from the very first commit). Instead
|
|
88
|
+
// it's an unresolved ambiguity, and it downgrades even a root-commit
|
|
89
|
+
// fallback claim to `partial` rather than asserting a possibly-wrong commit
|
|
90
|
+
// with high confidence — the two situations render identically because they
|
|
91
|
+
// ARE identical given only a `fixed` version and no lower bound.
|
|
92
|
+
let rootFallback = null;
|
|
93
|
+
let ambiguousBump = false;
|
|
94
|
+
|
|
95
|
+
for (const sha of candidates) {
|
|
96
|
+
if (deadlineAt && Date.now() > deadlineAt) return { status: 'budget_exhausted', commitsConsidered };
|
|
97
|
+
commitsConsidered++;
|
|
98
|
+
const blob = getBlobAtCommit(scanRoot, sha, file);
|
|
99
|
+
if (blob == null) continue;
|
|
100
|
+
const declaredVersion = extractDeclaredVersion(blob, scaEntry.name, file);
|
|
101
|
+
if (!declaredVersion || !versionInRange(declaredVersion, range)) continue;
|
|
102
|
+
|
|
103
|
+
const parent = getFirstParent(scanRoot, sha);
|
|
104
|
+
if (!parent) {
|
|
105
|
+
if (!rootFallback) {
|
|
106
|
+
const meta = commitMeta(scanRoot, sha);
|
|
107
|
+
if (meta) rootFallback = meta;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parentBlob = getBlobAtCommit(scanRoot, parent, file);
|
|
113
|
+
const parentVersion = parentBlob ? extractDeclaredVersion(parentBlob, scaEntry.name, file) : null;
|
|
114
|
+
const parentOutOfRange = !parentVersion || !versionInRange(parentVersion, range);
|
|
115
|
+
if (!parentOutOfRange) {
|
|
116
|
+
// Parent was already in the vulnerable range too. If the declared value
|
|
117
|
+
// actually changed here, this is the ambiguous "still-vulnerable bump"
|
|
118
|
+
// case described above — never attribute origin to it, and don't trust
|
|
119
|
+
// an earlier root fallback either, since it proves the history isn't as
|
|
120
|
+
// clean as a single unchanging value since repo creation.
|
|
121
|
+
if (parentVersion !== declaredVersion) ambiguousBump = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const meta = commitMeta(scanRoot, sha);
|
|
126
|
+
if (!meta) continue;
|
|
127
|
+
return originResult({ meta, commitsConsidered, absentInParents: [parent], parentBoundaryVerified: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (rootFallback && !ambiguousBump) {
|
|
131
|
+
return originResult({ meta: rootFallback, commitsConsidered, absentInParents: [], parentBoundaryVerified: false });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
status: 'partial',
|
|
136
|
+
reason: ambiguousBump ? 'ambiguous-range-no-introduced-bound' : 'version-never-confirmed-in-candidates',
|
|
137
|
+
commitsConsidered,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const FINDING_PROVENANCE_SCHEMA_VERSION = '1.0';
|
|
4
|
+
|
|
5
|
+
export const PROVENANCE_STATUS = Object.freeze({
|
|
6
|
+
COMPLETE: 'complete',
|
|
7
|
+
PARTIAL: 'partial',
|
|
8
|
+
NOT_AVAILABLE: 'not_available',
|
|
9
|
+
UNCOMMITTED: 'uncommitted',
|
|
10
|
+
BUDGET_EXHAUSTED: 'budget_exhausted',
|
|
11
|
+
ERROR: 'error',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const PROVENANCE_METHOD = Object.freeze({
|
|
15
|
+
SEMANTIC_REPLAY: 'semantic-history-replay',
|
|
16
|
+
DEPENDENCY_GRAPH_DIFF: 'dependency-graph-diff',
|
|
17
|
+
LINE_ATTRIBUTION: 'line-attribution',
|
|
18
|
+
SCAN_HISTORY: 'scan-history',
|
|
19
|
+
// FR-PROV-017: a missing-control-resolver.js regression — "commit removed a
|
|
20
|
+
// previously-present safeguard" — not a code-INTRODUCTION event, so it is
|
|
21
|
+
// kept distinct from SEMANTIC_REPLAY (which always names the commit that
|
|
22
|
+
// introduced a bad pattern). A reader relying on `method` to interpret what
|
|
23
|
+
// `findingOrigin` means must be able to tell the two apart.
|
|
24
|
+
MISSING_CONTROL_REGRESSION: 'missing-control-regression',
|
|
25
|
+
NONE: 'none',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const CONFIDENCE_LEVEL = Object.freeze({ HIGH: 'high', MEDIUM: 'medium', LOW: 'low', UNKNOWN: 'unknown' });
|
|
29
|
+
|
|
30
|
+
// PRD Section 8 "Compliance evidence boundary" — REQUIRED DISCLAIMER, verbatim.
|
|
31
|
+
// Second independent audit (2026-08-29): this exact sentence appeared nowhere
|
|
32
|
+
// in shipped code or docs. It is distinct from evidence-grade-wording.js's
|
|
33
|
+
// EVIDENCE_GRADE_DISCLAIMER_SHORT (which disclaims the WALKTHROUGH narrative
|
|
34
|
+
// as a whole — "this isn't a certification/attestation") — this one disclaims
|
|
35
|
+
// what git PROVENANCE data specifically can and cannot support once it is
|
|
36
|
+
// used as compliance evidence. Any renderer that surfaces a findingProvenance-
|
|
37
|
+
// derived fact (an origin commit, an author, a confidence level) alongside a
|
|
38
|
+
// compliance claim must show this next to it. Kept verbatim per the PRD
|
|
39
|
+
// rather than paraphrased, so a reader comparing this tool's output against
|
|
40
|
+
// the PRD text can confirm the boundary was actually implemented.
|
|
41
|
+
export const PROVENANCE_COMPLIANCE_DISCLAIMER =
|
|
42
|
+
'Provenance establishes repository history for technical evidence. It does not prove ' +
|
|
43
|
+
'developer intent, control operation outside code, organizational compliance, or certification.';
|
|
44
|
+
|
|
45
|
+
// Second independent Finding Provenance PRD audit: this enum is the PRD's
|
|
46
|
+
// full role vocabulary, but not every value has a producer. Honest status
|
|
47
|
+
// per role, so a reader does not assume "defined here" means "emitted
|
|
48
|
+
// somewhere".
|
|
49
|
+
//
|
|
50
|
+
// LIVE (something in src/ actually constructs one): SOURCE, SINK,
|
|
51
|
+
// TRANSFORMATION (evidence-attribution.js's source/sink/pathSteps walk);
|
|
52
|
+
// MANIFEST (coordinator.js's SCA evidence node); REMOVED_GUARD via the
|
|
53
|
+
// missingControlCandidate hint (rate-limit.js's routes,
|
|
54
|
+
// evidence-attribution.js's `opts.removedGuard`); SECRET via the
|
|
55
|
+
// `findingType: 'secret'` hint (`opts.secret`).
|
|
56
|
+
//
|
|
57
|
+
// DECLARED BUT EMITTED BY NOTHING: GUARD ("a control IS present at this
|
|
58
|
+
// location"), CONFIG ("a misconfigured setting, not a taint sink"),
|
|
59
|
+
// LOCKFILE, and OTHER. Verified by grep, not assumed. Wiring GUARD or
|
|
60
|
+
// CONFIG would mean new detector-side work (a "guard present" evidence
|
|
61
|
+
// producer, or classifying which SAST findings are config-shaped), out of
|
|
62
|
+
// scope for a schema-fidelity fix. LOCKFILE is the subtler one and the
|
|
63
|
+
// reason this list is spelled out rather than summarized: the transitive-SCA
|
|
64
|
+
// path IS lockfile-diff-derived, so it reads as though it should emit
|
|
65
|
+
// LOCKFILE — but coordinator.js labels both the direct and transitive SCA
|
|
66
|
+
// evidence nodes MANIFEST, so LOCKFILE has no producer at all. (An earlier
|
|
67
|
+
// version of this very comment grouped it with the live roles; a review
|
|
68
|
+
// caught it. The failure mode this comment exists to prevent is easy to
|
|
69
|
+
// reintroduce inside the comment itself — check `grep -rn
|
|
70
|
+
// "EVIDENCE_ROLE.<NAME>" src/` before moving any role between these lists.)
|
|
71
|
+
//
|
|
72
|
+
// None of these are dead in the sense of unreachable code — the enum values
|
|
73
|
+
// and their consumers (`validate.js` callers, PRD-shaped JSON readers) are
|
|
74
|
+
// correct — they are simply never produced today.
|
|
75
|
+
export const EVIDENCE_ROLE = Object.freeze({
|
|
76
|
+
SOURCE: 'source', SINK: 'sink', GUARD: 'guard', REMOVED_GUARD: 'removed_guard',
|
|
77
|
+
TRANSFORMATION: 'transformation', CONFIG: 'config', SECRET: 'secret',
|
|
78
|
+
MANIFEST: 'manifest', LOCKFILE: 'lockfile', OTHER: 'other',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// M3: the AGE_BASIS enum, re-added now that M2 shipped its two consumers
|
|
82
|
+
// (mttr.js's ageBasis field, fix-history.js's provenanceAtFix.ageBasis) —
|
|
83
|
+
// both previously used bare string literals matching this vocabulary
|
|
84
|
+
// without importing a shared source of truth for it.
|
|
85
|
+
export const AGE_BASIS = Object.freeze({
|
|
86
|
+
FINDING_ORIGIN: 'finding_origin',
|
|
87
|
+
EARLIEST_OBSERVABLE: 'earliest_observable',
|
|
88
|
+
FIRST_OBSERVED: 'first_observed',
|
|
89
|
+
UNCOMMITTED: 'uncommitted',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// The `['complete', 'uncommitted']` "provenance is healthy enough to trust"
|
|
93
|
+
// check independently existed in TWO places (bin/agentic-security.js's
|
|
94
|
+
// --require-provenance block and pipeline/assurance-mode.js's strict check),
|
|
95
|
+
// both re-deriving the same status set locally; unified into this one shared
|
|
96
|
+
// predicate, so a future change to what counts as "healthy" is a one-line
|
|
97
|
+
// edit, not a grep-and-fix-N-places. mttr.js's/fix-history.js's ageBasis
|
|
98
|
+
// tiering is a DIFFERENT thing — it buckets a finding's age basis for
|
|
99
|
+
// reporting, not a healthy/unhealthy gate — and was never converted to use
|
|
100
|
+
// this predicate; do not conflate the two.
|
|
101
|
+
export function isProvenanceHealthy(findingProvenance) {
|
|
102
|
+
return ['complete', 'uncommitted'].includes(findingProvenance?.status);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function emptyProvenance(status, extra = {}) {
|
|
106
|
+
return {
|
|
107
|
+
schemaVersion: FINDING_PROVENANCE_SCHEMA_VERSION,
|
|
108
|
+
status,
|
|
109
|
+
findingOrigin: null,
|
|
110
|
+
branchIntroduction: null,
|
|
111
|
+
// FR-PROV-022: PR/reviewer/CODEOWNERS metadata is a distinct concern from
|
|
112
|
+
// "which commit/branch introduced this" (branchIntroduction) — a sibling
|
|
113
|
+
// top-level field, not nested inside it, matching this schema's existing
|
|
114
|
+
// convention of keeping origin/branch-entry/evidence-attribution as
|
|
115
|
+
// separate top-level objects. Populated only when a provider is
|
|
116
|
+
// configured AND a PR/MR was actually found for the origin commit;
|
|
117
|
+
// otherwise stays at this default (never a half-filled object).
|
|
118
|
+
providerEnrichment: null,
|
|
119
|
+
firstObserved: null,
|
|
120
|
+
evidenceAttribution: [],
|
|
121
|
+
method: PROVENANCE_METHOD.NONE,
|
|
122
|
+
confidence: { level: CONFIDENCE_LEVEL.UNKNOWN, score: 0, reasons: [] },
|
|
123
|
+
historyCoverage: { complete: false, shallow: false, boundaryCommit: null, commitsConsidered: 0, crossRepoLineage: false },
|
|
124
|
+
analysisBasis: { head: null, ruleset: null, detector: null, dirty: false },
|
|
125
|
+
limitations: [],
|
|
126
|
+
evidenceDigest: null,
|
|
127
|
+
...extra,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A stable, one-way pseudonym for an author -- PRD Section 8: "support
|
|
133
|
+
* organization policy to pseudonymize names while retaining an internal
|
|
134
|
+
* stable identity reference." Keyed on email when available (more stable
|
|
135
|
+
* across commits than a display name, which can vary in capitalization or
|
|
136
|
+
* formatting across different commits by the same person) with a fallback
|
|
137
|
+
* to name alone. Deterministic: the SAME author always gets the SAME
|
|
138
|
+
* pseudonym within one run (and across runs, since it's a pure function of
|
|
139
|
+
* the input, not randomized) -- this is what "stable identity reference"
|
|
140
|
+
* means: a reader can tell "these five findings share an author" without
|
|
141
|
+
* learning who that author is.
|
|
142
|
+
*/
|
|
143
|
+
export function pseudonymizeAuthor(authorName, authorEmail) {
|
|
144
|
+
const key = authorEmail || authorName || '';
|
|
145
|
+
if (!key) return 'Contributor-unknown';
|
|
146
|
+
const hash = crypto.createHash('sha256').update(key).digest('hex').slice(0, 8);
|
|
147
|
+
return `Contributor-${hash}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Fix-round item 3: `providerEnrichment` (FR-PROV-022) carries reviewer
|
|
151
|
+
// logins and raw CODEOWNERS lines -- both routinely name real people
|
|
152
|
+
// (GitHub/GitLab usernames as `@handle`s, and CODEOWNERS lines commonly
|
|
153
|
+
// embed plain email addresses too) -- but were never touched by
|
|
154
|
+
// `redactFindingProvenance`, so `--pseudonymize-authors` redacted
|
|
155
|
+
// `findingOrigin.authorName` while `providerEnrichment.reviewers`/
|
|
156
|
+
// `codeowners` still carried real identities in plain text through
|
|
157
|
+
// `.agentic-security/findings.json` and SARIF. Same two knobs as
|
|
158
|
+
// `redactOrigin` below: `includeEmail` gates raw email ADDRESSES embedded in
|
|
159
|
+
// a codeowners line (withheld by default, same precedent as
|
|
160
|
+
// `origin.authorEmail`); `pseudonymize` replaces any identifier (a reviewer
|
|
161
|
+
// login, an `@handle` in a codeowners line, or an embedded email substring)
|
|
162
|
+
// with `pseudonymizeAuthor`'s stable per-identifier hash, so re-running
|
|
163
|
+
// produces the SAME pseudonym for the SAME person rather than a blanket
|
|
164
|
+
// redaction that destroys the "which findings share a reviewer" structure.
|
|
165
|
+
// Neither knob touches a bare login/handle when `pseudonymize` is off --
|
|
166
|
+
// same precedent as `authorName`, which is NOT redacted by default either.
|
|
167
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
168
|
+
// `.`/`_` included: GitLab usernames legally contain both (`@r.smith`,
|
|
169
|
+
// `@jane_doe`) — a narrower class left real surname fragments unredacted
|
|
170
|
+
// under `pseudonymize:true` (found in the plan's own re-review of this fix).
|
|
171
|
+
const HANDLE_RE = /@[A-Za-z0-9][A-Za-z0-9._-]*/g;
|
|
172
|
+
|
|
173
|
+
function redactEmailSubstrings(text, { includeEmail, pseudonymize }) {
|
|
174
|
+
if (typeof text !== 'string') return text;
|
|
175
|
+
if (includeEmail && !pseudonymize) return text;
|
|
176
|
+
return text.replace(EMAIL_RE, (email) => (pseudonymize ? pseudonymizeAuthor(null, email) : '[email redacted]'));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function redactHandleSubstrings(text, { pseudonymize }) {
|
|
180
|
+
if (typeof text !== 'string' || !pseudonymize) return text;
|
|
181
|
+
// Runs AFTER redactEmailSubstrings, so an email's local-part `@` has
|
|
182
|
+
// already been consumed by the full-email replacement above and cannot be
|
|
183
|
+
// mistaken for a bare handle here.
|
|
184
|
+
return text.replace(HANDLE_RE, (match) => `@${pseudonymizeAuthor(match.slice(1), null)}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function redactCodeownersLine(line, opts) {
|
|
188
|
+
if (typeof line !== 'string') return line;
|
|
189
|
+
return redactHandleSubstrings(redactEmailSubstrings(line, opts), opts);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function redactProviderEnrichment(pe, opts) {
|
|
193
|
+
if (!pe) return null;
|
|
194
|
+
return {
|
|
195
|
+
...pe,
|
|
196
|
+
reviewers: Array.isArray(pe.reviewers)
|
|
197
|
+
? pe.reviewers.map((r) => (opts.pseudonymize ? pseudonymizeAuthor(r, null) : r))
|
|
198
|
+
: pe.reviewers,
|
|
199
|
+
codeowners: Array.isArray(pe.codeowners)
|
|
200
|
+
? pe.codeowners.map((line) => redactCodeownersLine(line, opts))
|
|
201
|
+
: pe.codeowners,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function redactFindingProvenance(fp, { includeEmail = false, pseudonymize = false } = {}) {
|
|
206
|
+
if (!fp) return null;
|
|
207
|
+
const redactOrigin = (origin) => origin
|
|
208
|
+
? {
|
|
209
|
+
...origin,
|
|
210
|
+
authorEmail: includeEmail ? origin.authorEmail : null,
|
|
211
|
+
authorName: pseudonymize ? pseudonymizeAuthor(origin.authorName, origin.authorEmail) : origin.authorName,
|
|
212
|
+
}
|
|
213
|
+
: null;
|
|
214
|
+
return {
|
|
215
|
+
...fp,
|
|
216
|
+
findingOrigin: redactOrigin(fp.findingOrigin),
|
|
217
|
+
providerEnrichment: redactProviderEnrichment(fp.providerEnrichment, { includeEmail, pseudonymize }),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Untrusted commit metadata (author name, commit summary) must never reach
|
|
222
|
+
// a terminal or a Markdown/HTML renderer un-sanitized — PRD FR-PROV-026.
|
|
223
|
+
// Strips ANSI/control escape sequences (a malicious author name containing
|
|
224
|
+
// \x1b[2J or similar could manipulate terminal state) and collapses
|
|
225
|
+
// newlines (a multi-line "author name" could forge extra output lines).
|
|
226
|
+
// This is layered UNDER the existing HTML-escaping path (which already
|
|
227
|
+
// protects the HTML renderer via JSON-escaping before embedding) — this
|
|
228
|
+
// helper is specifically for the CLI/terminal text path, which had none.
|
|
229
|
+
//
|
|
230
|
+
// Lives here (rather than in report/index.js, which re-exports it) because
|
|
231
|
+
// it has consumers on opposite sides of the report/posture boundary:
|
|
232
|
+
// report/index.js's explainProvenance/toMarkdown, and
|
|
233
|
+
// posture/auditor-walkthrough.js's renderWalkthrough — both of which are
|
|
234
|
+
// console.log'd verbatim (bin/agentic-security.js), so both need the
|
|
235
|
+
// terminal-safety guarantee. Putting it in report/ would make posture/
|
|
236
|
+
// depend on report/, backwards from every other dependency in this tree.
|
|
237
|
+
//
|
|
238
|
+
// A Markdown-specific sibling (backslash-escaping CommonMark punctuation)
|
|
239
|
+
// was added and then removed here: it was applied to renderWalkthrough's
|
|
240
|
+
// output, but that text's only LIVE consumer is bin/agentic-security.js's
|
|
241
|
+
// raw `console.log`, and its file-writing sibling (persistWalkthrough) has
|
|
242
|
+
// zero callers anywhere in the CLI/command surface — nothing in this repo
|
|
243
|
+
// ever runs this text through an actual Markdown renderer. Backslash-
|
|
244
|
+
// escaping is only inert once a real renderer processes it; printed raw or
|
|
245
|
+
// read as a plain-text file, `Jean-Luc Picard` becoming `Jean\-Luc Picard`
|
|
246
|
+
// and `dependabot[bot]` becoming `dependabot\[bot\]` is a visible
|
|
247
|
+
// regression on ordinary, common real-world names, not a security fix. If
|
|
248
|
+
// a genuine Markdown-rendering consumer of this text is added later, revisit
|
|
249
|
+
// this decision informed by that consumer's actual escaping requirements —
|
|
250
|
+
// don't reintroduce blind punctuation-escaping speculatively.
|
|
251
|
+
export function sanitizeForTerminal(str) {
|
|
252
|
+
if (typeof str !== 'string') return str;
|
|
253
|
+
// eslint-disable-next-line no-control-regex
|
|
254
|
+
return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').replace(/[\r\n]+/g, ' ').trim();
|
|
255
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Transitive dependency origin resolution (Finding Provenance PRD, M3 §3.2).
|
|
2
|
+
//
|
|
3
|
+
// A transitive vulnerable_dep finding's vulnerable version was never
|
|
4
|
+
// declared directly in this repo's manifest — some DIRECT dependency's
|
|
5
|
+
// version bump pulled it into the graph. This module answers "which commit
|
|
6
|
+
// changed the lockfile such that the vulnerable transitive version first
|
|
7
|
+
// appears," re-deriving the dependency's ancestry AT EACH HISTORICAL
|
|
8
|
+
// CANDIDATE COMMIT rather than trusting the CURRENT depChain (Task 5) —
|
|
9
|
+
// the graph's shape can itself change across commits, so the current
|
|
10
|
+
// ancestry is a hint for the fixture/test author, not evidence a resolver
|
|
11
|
+
// can rely on for a historical claim.
|
|
12
|
+
//
|
|
13
|
+
// Scope: package-lock.json only for M3 — see the plan's Task 6 scope note.
|
|
14
|
+
|
|
15
|
+
import { candidateCommitsForFile, getFirstParent, getBlobAtCommit, commitMeta } from './git-evidence.js';
|
|
16
|
+
import { versionInRange } from './sca-origin.js';
|
|
17
|
+
import { PROVENANCE_METHOD } from './schema.js';
|
|
18
|
+
|
|
19
|
+
const LOCKFILE_BASENAME = 'package-lock.json';
|
|
20
|
+
|
|
21
|
+
function _sameDepChain(a, b) {
|
|
22
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
23
|
+
return a.every((seg, i) => seg === b[i]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract {version, depChain} for `depName` from a package-lock.json blob's
|
|
27
|
+
// text, using the same `packages` key structure engine.js's
|
|
28
|
+
// _parsePackageLockJson reads at scan time (lockfile v2/v3 shape: keys are
|
|
29
|
+
// paths like "node_modules/express/node_modules/qs"). Returns null if the
|
|
30
|
+
// package isn't present in this blob at all (e.g., an OLDER lockfile before
|
|
31
|
+
// it was ever pulled in).
|
|
32
|
+
//
|
|
33
|
+
// `preferredDepChain` (M3 §3.2 follow-up, final whole-branch review item #7)
|
|
34
|
+
// is the finding's OWN depChain (Task 5, propagated from engine.js's
|
|
35
|
+
// _parsePackageLockJson) identifying which specific nested copy the finding
|
|
36
|
+
// is actually about, when a lockfile has multiple nested copies of the same
|
|
37
|
+
// package name at different depths. When a `packages` key at THIS historical
|
|
38
|
+
// blob computes to that exact chain, it wins outright — the shortest-path
|
|
39
|
+
// heuristic below is a heuristic guess about "the" instance, and an exact
|
|
40
|
+
// match is not a guess. It is only ever a hint, though, per this module's own
|
|
41
|
+
// header comment: the graph's shape can differ commit-to-commit, so a
|
|
42
|
+
// historical blob may simply have no key whose chain matches the CURRENT
|
|
43
|
+
// depChain at all — in that case fall back to the shortest-path heuristic
|
|
44
|
+
// exactly as before, since re-deriving ancestry per-commit rather than
|
|
45
|
+
// trusting a mismatched hint is the whole reason this module doesn't just
|
|
46
|
+
// trust the current depChain outright.
|
|
47
|
+
function extractTransitiveVersion(blobText, depName, preferredDepChain) {
|
|
48
|
+
let doc;
|
|
49
|
+
try { doc = JSON.parse(blobText); } catch { return null; }
|
|
50
|
+
const packages = doc.packages;
|
|
51
|
+
if (!packages || typeof packages !== 'object') return null;
|
|
52
|
+
// Prefer the SHORTEST matching path (closest to a direct dependency) when
|
|
53
|
+
// multiple nested copies of the same package exist at different depths —
|
|
54
|
+
// matches the ancestry a reader would consider "the" instance most of the
|
|
55
|
+
// time. Ties resolve to whichever JSON.stringify/Object.keys ordering
|
|
56
|
+
// returns first (lockfiles preserve insertion order; this is deterministic
|
|
57
|
+
// for a given blob, which is what matters for a repeatable resolution).
|
|
58
|
+
let best = null;
|
|
59
|
+
let exact = null;
|
|
60
|
+
for (const key of Object.keys(packages)) {
|
|
61
|
+
if (!key.endsWith(`node_modules/${depName}`)) continue;
|
|
62
|
+
const depChain = key.split('node_modules/').filter(Boolean).map((s) => s.replace(/\/$/, ''));
|
|
63
|
+
const entry = packages[key];
|
|
64
|
+
const version = entry && entry.version;
|
|
65
|
+
if (!version) continue;
|
|
66
|
+
if (!exact && Array.isArray(preferredDepChain) && preferredDepChain.length > 0 && _sameDepChain(depChain, preferredDepChain)) {
|
|
67
|
+
exact = { version, depChain };
|
|
68
|
+
}
|
|
69
|
+
if (!best || depChain.length < best.depChain.length) best = { version, depChain };
|
|
70
|
+
}
|
|
71
|
+
return exact || best;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function originResult({ meta, commitsConsidered, depChain, absentInParents, parentBoundaryVerified }) {
|
|
75
|
+
return {
|
|
76
|
+
status: 'complete', method: PROVENANCE_METHOD.DEPENDENCY_GRAPH_DIFF, commitsConsidered,
|
|
77
|
+
findingOrigin: {
|
|
78
|
+
commit: meta.commit, authorName: meta.authorName, authorEmail: meta.authorEmail,
|
|
79
|
+
authorDate: meta.authorDate, committerDate: meta.committerDate, summary: meta.summary,
|
|
80
|
+
presentInCommit: true, absentInParents, revertOf: null, cherryPickOf: null,
|
|
81
|
+
},
|
|
82
|
+
parentBoundaryVerified,
|
|
83
|
+
depChain,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function resolveTransitiveSCAOrigin(scanRoot, scaEntry, { since, deadlineAt } = {}) {
|
|
88
|
+
const file = scaEntry.filePath || scaEntry.file;
|
|
89
|
+
if (!file) return { status: 'not_available', reason: 'no-manifest-path', commitsConsidered: 0 };
|
|
90
|
+
const basename = file.split('/').pop();
|
|
91
|
+
if (basename !== LOCKFILE_BASENAME) {
|
|
92
|
+
return { status: 'not_available', reason: 'unsupported-lockfile-format', commitsConsidered: 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const candidates = candidateCommitsForFile(scanRoot, file, { since });
|
|
96
|
+
if (candidates.length === 0) return { status: 'not_available', reason: 'no-candidate-commits', commitsConsidered: 0 };
|
|
97
|
+
|
|
98
|
+
const range = { introduced: null, fixed: (scaEntry.fixedVersions || [])[0] || null };
|
|
99
|
+
const preferredDepChain = Array.isArray(scaEntry.depChain) ? scaEntry.depChain : null;
|
|
100
|
+
let commitsConsidered = 0;
|
|
101
|
+
let rootFallback = null;
|
|
102
|
+
let rootFallbackChain = null;
|
|
103
|
+
let ambiguousBump = false;
|
|
104
|
+
|
|
105
|
+
for (const sha of candidates) {
|
|
106
|
+
if (deadlineAt && Date.now() > deadlineAt) return { status: 'budget_exhausted', commitsConsidered };
|
|
107
|
+
commitsConsidered++;
|
|
108
|
+
const blob = getBlobAtCommit(scanRoot, sha, file);
|
|
109
|
+
if (blob == null) continue;
|
|
110
|
+
const declared = extractTransitiveVersion(blob, scaEntry.name, preferredDepChain);
|
|
111
|
+
if (!declared || !versionInRange(declared.version, range)) continue;
|
|
112
|
+
|
|
113
|
+
const parent = getFirstParent(scanRoot, sha);
|
|
114
|
+
if (!parent) {
|
|
115
|
+
if (!rootFallback) {
|
|
116
|
+
const meta = commitMeta(scanRoot, sha);
|
|
117
|
+
if (meta) { rootFallback = meta; rootFallbackChain = declared.depChain; }
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const parentBlob = getBlobAtCommit(scanRoot, parent, file);
|
|
123
|
+
const parentDeclared = parentBlob ? extractTransitiveVersion(parentBlob, scaEntry.name, preferredDepChain) : null;
|
|
124
|
+
const parentOutOfRange = !parentDeclared || !versionInRange(parentDeclared.version, range);
|
|
125
|
+
if (!parentOutOfRange) {
|
|
126
|
+
// Same ambiguity sca-origin.js's resolveDirectSCAOrigin documents: a
|
|
127
|
+
// fixed-only range with no lower bound can't distinguish "vulnerable
|
|
128
|
+
// since inception, unrelated bump" from "just became vulnerable here."
|
|
129
|
+
if (parentDeclared.version !== declared.version) ambiguousBump = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const meta = commitMeta(scanRoot, sha);
|
|
134
|
+
if (!meta) continue;
|
|
135
|
+
return originResult({ meta, commitsConsidered, depChain: declared.depChain, absentInParents: [parent], parentBoundaryVerified: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (rootFallback && !ambiguousBump) {
|
|
139
|
+
return originResult({ meta: rootFallback, commitsConsidered, depChain: rootFallbackChain, absentInParents: [], parentBoundaryVerified: false });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
status: 'partial',
|
|
144
|
+
reason: ambiguousBump ? 'ambiguous-range-no-introduced-bound' : 'version-never-confirmed-in-candidates',
|
|
145
|
+
commitsConsidered,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PROVENANCE_STATUS, FINDING_PROVENANCE_SCHEMA_VERSION } from './schema.js';
|
|
2
|
+
|
|
3
|
+
const VALID_STATUSES = new Set(Object.values(PROVENANCE_STATUS));
|
|
4
|
+
|
|
5
|
+
export function validateFindingProvenance(finding) {
|
|
6
|
+
const errors = [];
|
|
7
|
+
const fp = finding && finding.findingProvenance;
|
|
8
|
+
if (!fp || typeof fp !== 'object') {
|
|
9
|
+
errors.push('missing findingProvenance object');
|
|
10
|
+
return { valid: false, errors };
|
|
11
|
+
}
|
|
12
|
+
if (!VALID_STATUSES.has(fp.status)) errors.push(`invalid status: ${fp.status}`);
|
|
13
|
+
if (!Array.isArray(fp.evidenceAttribution)) errors.push('evidenceAttribution must be an array');
|
|
14
|
+
if (!Array.isArray(fp.limitations)) errors.push('limitations must be an array');
|
|
15
|
+
// A version field nothing ever compares against is decoration, not a
|
|
16
|
+
// contract. These objects are persisted (last-scan.json, the provenance
|
|
17
|
+
// cache) and read back by later releases, so an object stamped with a
|
|
18
|
+
// version this build does not know is exactly what the field exists to
|
|
19
|
+
// catch — and it is the only reason to carry it at all.
|
|
20
|
+
if (!fp.schemaVersion) errors.push('missing schemaVersion');
|
|
21
|
+
else if (fp.schemaVersion !== FINDING_PROVENANCE_SCHEMA_VERSION) {
|
|
22
|
+
errors.push(`unsupported schemaVersion: ${fp.schemaVersion} (this build understands ${FINDING_PROVENANCE_SCHEMA_VERSION})`);
|
|
23
|
+
}
|
|
24
|
+
return { valid: errors.length === 0, errors };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateFindingsProvenance(findings) {
|
|
28
|
+
const results = (findings || []).map((f) => ({ id: f.id, ...validateFindingProvenance(f) }));
|
|
29
|
+
return { valid: results.every((r) => r.valid), failures: results.filter((r) => !r.valid) };
|
|
30
|
+
}
|