@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.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.
Files changed (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
@@ -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
+ }