@preventive/triage 1.0.0-alpha.52 → 1.0.0-alpha.54

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 (112) hide show
  1. package/common/bundle-compare-diff.js +28 -9
  2. package/common/bundle-compare-inputs.d.ts +1 -0
  3. package/common/bundle-compare-inputs.js +91 -15
  4. package/common/bundle-comparison.js +14 -7
  5. package/common/default-scan-models.ts +3 -0
  6. package/common/dependency-reviews.d.ts +1 -1
  7. package/common/dependency-reviews.js +1 -1
  8. package/common/managed/finding-detail.d.ts +4 -2
  9. package/common/managed/finding-detail.js +27 -15
  10. package/common/managed/team-app.d.ts +9 -0
  11. package/common/managed/team-app.js +59 -6
  12. package/common/workspace-app.js +38 -6
  13. package/out/activity.js +4 -4
  14. package/out/chunk-4EMM6UCD.js +1 -0
  15. package/out/chunk-4ISO5F7Y.js +7 -0
  16. package/out/chunk-4XIH7YTX.js +1 -0
  17. package/out/chunk-6W5BMATD.js +1 -0
  18. package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
  19. package/out/chunk-DEVAORP2.js +1 -0
  20. package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
  21. package/out/chunk-E3LFSUDJ.js +9 -0
  22. package/out/chunk-FHIVVVUA.js +6 -0
  23. package/out/chunk-G3LY2FV4.js +1 -0
  24. package/out/chunk-HAZKDK4M.js +4 -0
  25. package/out/chunk-HOB3YPS5.js +1 -0
  26. package/out/chunk-I47TYPXO.js +52 -0
  27. package/out/chunk-J7A5OSZE.js +244 -0
  28. package/out/chunk-JFL7WLRV.js +1 -0
  29. package/out/chunk-JMG5DXII.js +2 -0
  30. package/out/chunk-JOXLZXMI.js +1 -0
  31. package/out/chunk-L4CTEMRG.js +2 -0
  32. package/out/chunk-M37X4LCF.js +14 -0
  33. package/out/chunk-M3Y6SLC4.js +1 -0
  34. package/out/chunk-NXATZJQ2.js +1 -0
  35. package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
  36. package/out/chunk-PAD2JHZT.js +1 -0
  37. package/out/chunk-QMVOEBWC.js +13 -0
  38. package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
  39. package/out/chunk-RRBIJL2B.js +1 -0
  40. package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
  41. package/out/chunk-TOXMUOIW.js +5 -0
  42. package/out/chunk-UBOCLLF2.js +1 -0
  43. package/out/chunk-UCP3JRE2.js +1 -0
  44. package/out/{chunk-NCN3M5EP.js → chunk-VC72ZO3P.js} +2 -2
  45. package/out/chunk-VRX2APNA.js +9 -0
  46. package/out/{chunk-IMM274IW.js → chunk-VZMELPVL.js} +4 -3
  47. package/out/{chunk-CHRBUGVN.js → chunk-WHVXTJHK.js} +1 -1
  48. package/out/chunk-WMFDQS2L.js +11 -0
  49. package/out/chunk-WNSVBOSO.js +41 -0
  50. package/out/chunk-WREU7JAG.js +1 -0
  51. package/out/chunk-Y5ZHFNBD.js +2 -0
  52. package/out/chunk-YF4M5OCI.js +1 -0
  53. package/out/chunk-Z667U2UJ.js +1 -0
  54. package/out/chunk-ZQHLECTI.js +1 -0
  55. package/out/client-managed-import.js +1 -1
  56. package/out/client-managed.js +27 -26
  57. package/out/compare.js +14 -14
  58. package/out/dialogs.js +36 -51
  59. package/out/graph.js +17 -15
  60. package/out/index.html +26 -10
  61. package/out/prism.js +1 -1
  62. package/out/scan.js +2 -2
  63. package/out/terminal.js +232 -232
  64. package/out/view.css +1 -1
  65. package/out/view.js +15 -15
  66. package/package.json +12 -7
  67. package/risk/actions/resolve.js +36 -5
  68. package/risk/index.d.ts +20 -2
  69. package/risk/npm/malware.js +6 -4
  70. package/risk/npm/registry.js +8 -6
  71. package/server-managed/bundle-advisories.ts +56 -74
  72. package/server-managed/bundle-build-worker.js +61 -4
  73. package/server-managed/bundle-build.ts +13 -4
  74. package/server-managed/bundle-dependency-reviews.ts +74 -0
  75. package/server-managed/bundle-validation.ts +32 -0
  76. package/server-managed/db-methods.ts +51 -16
  77. package/server-managed/db-neon.ts +18 -2
  78. package/server-managed/db.ts +11 -2
  79. package/server-managed/github-issue-links.ts +151 -47
  80. package/server-managed/github-pulls.ts +34 -15
  81. package/server-managed/history-windows.ts +172 -0
  82. package/server-managed/http.ts +227 -12
  83. package/server-managed/index.ts +13 -1
  84. package/server-managed/managed-issues.ts +29 -15
  85. package/server-managed/npm-insights.ts +4 -3
  86. package/server-managed/npm-kept.ts +4 -0
  87. package/server-managed/package-entry-points.ts +37 -9
  88. package/server-managed/pretty-print.ts +7 -5
  89. package/server-managed/public-workspace.ts +2 -2
  90. package/server-managed/repository-actions.ts +7 -4
  91. package/server-managed/repository-browser.ts +11 -4
  92. package/server-managed/repository-history.ts +2 -2
  93. package/server-managed/repository-pulse.ts +11 -2
  94. package/server-managed/repository-risk-audit.ts +4 -5
  95. package/server-managed/repository-risk.ts +4 -4
  96. package/server-managed/sql.ts +2 -1
  97. package/server-managed/stasis-config.ts +46 -0
  98. package/server-managed/team-app-summaries.ts +54 -0
  99. package/server-managed/team-app.ts +52 -31
  100. package/server-managed/team-reports.ts +34 -13
  101. package/server-managed/upstream-cache.ts +41 -32
  102. package/server-managed/workspace-shares.ts +3 -1
  103. package/out/chunk-2ZDZWLFY.js +0 -1
  104. package/out/chunk-7VAWINQF.js +0 -6
  105. package/out/chunk-FMXWXDR4.js +0 -36
  106. package/out/chunk-IZ7POWI3.js +0 -18
  107. package/out/chunk-LJ7XRXFU.js +0 -52
  108. package/out/chunk-OGAYMEER.js +0 -232
  109. package/out/chunk-QB4CBOX2.js +0 -1
  110. package/out/chunk-TQJDYHVE.js +0 -2
  111. package/out/chunk-VH4XMZIW.js +0 -2
  112. package/out/chunk-WYLQWYLA.js +0 -1
@@ -1,10 +1,13 @@
1
1
  import type { ManagedSql } from './sql.ts'
2
2
  import type { TeamReportAccessSnapshot } from './db.ts'
3
- import { parseGithubPrUrl } from '../common/github-pr.ts'
3
+ import { parseGithubIssueUrl, parseGithubPrUrl } from '../common/github-pr.ts'
4
4
 
5
5
  // One immutable reference per finding, shared across reports and teams. Keep
6
6
  // reservations across crashes/uncertain GitHub responses to prevent duplicates.
7
- // No triage/import/admin patch accepts these fields.
7
+ // No triage/import/admin patch accepts these fields. What a lookup derives of
8
+ // the issue (github-issue-links.ts) sits beside it, never in its place: the
9
+ // issue it was closed as a duplicate of, kept as a redirect is, and the pull
10
+ // request fixing either, both as last checked (auto_fix_checked_at).
8
11
  export const MANAGED_ISSUE_SCHEMA = `
9
12
  CREATE TABLE IF NOT EXISTS managed_finding_issue (
10
13
  finding_id TEXT PRIMARY KEY,
@@ -14,6 +17,7 @@ CREATE TABLE IF NOT EXISTS managed_finding_issue (
14
17
  issue_url TEXT,
15
18
  auto_fix_url TEXT,
16
19
  auto_fix_checked_at INTEGER,
20
+ duplicate_of_url TEXT,
17
21
  created_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
18
22
  created_at INTEGER NOT NULL
19
23
  ) STRICT;
@@ -26,16 +30,20 @@ export interface ManagedIssue {
26
30
  issueUrl: string | null
27
31
  autoFixUrl: string | null
28
32
  autoFixCheckedAt: number | null
33
+ duplicateOfUrl: string | null
29
34
  createdBy: string | null
30
35
  createdAt: number
31
36
  }
37
+ // What a lookup derives of a saved issue: the pull request fixing it, and the
38
+ // issue it was closed as a duplicate of (followed to the last such one).
39
+ export type IssueTracking = { autoFix: string | null; duplicateOf: string | null }
32
40
  export interface ManagedIssueStore {
33
41
  getManagedIssue(findingId: string): Promise<ManagedIssue | null>
34
42
  listManagedIssues(findingIds: readonly string[]): Promise<ManagedIssue[]>
35
- claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt'>): Promise<boolean>
43
+ claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt' | 'duplicateOfUrl'>): Promise<boolean>
36
44
  finishManagedIssue(findingId: string, requestId: string, url: string): Promise<boolean>
37
45
  releaseManagedIssue(findingId: string, requestId: string): Promise<void>
38
- setManagedIssueAutoFix(findingId: string, issueUrl: string, previous: string | null, next: string | null, checkedAt: number): Promise<boolean>
46
+ setManagedIssueTracking(findingId: string, issueUrl: string, previous: IssueTracking, next: IssueTracking, checkedAt: number): Promise<boolean>
39
47
  }
40
48
 
41
49
  // A repeated finding ID never grants access to another team's repository.
@@ -43,21 +51,26 @@ export function visibleManagedIssues(issues: ManagedIssue[], snapshot: TeamRepor
43
51
  if (snapshot.user.id.startsWith('share:')) return []
44
52
  const repos = new Set(snapshot.repositories.map(repo => repo.repoId))
45
53
  const names = new Set(snapshot.repositories.map(repo => repo.github.toLowerCase()))
46
- return issues.filter(issue => issue.issueUrl && repos.has(issue.repoId)).map(issue => {
47
- const ref = parseGithubPrUrl(issue.autoFixUrl)
48
- return { ...issue, autoFixUrl: ref && names.has(ref.repo.toLowerCase()) ? issue.autoFixUrl : null }
49
- })
54
+ const inTeam = (url: string | null, parse: typeof parseGithubPrUrl) => {
55
+ const ref = parse(url)
56
+ return ref && names.has(ref.repo.toLowerCase()) ? url : null
57
+ }
58
+ return issues.filter(issue => issue.issueUrl && repos.has(issue.repoId)).map(issue =>
59
+ ({ ...issue, autoFixUrl: inTeam(issue.autoFixUrl, parseGithubPrUrl), duplicateOfUrl: inTeam(issue.duplicateOfUrl, parseGithubIssueUrl) }))
50
60
  }
51
61
  export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
52
62
  const fields = `finding_id AS findingId, repo_id AS repoId, repository, request_id AS requestId,
53
- issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, created_by AS createdBy, created_at AS createdAt`
63
+ issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, duplicate_of_url AS duplicateOfUrl,
64
+ created_by AS createdBy, created_at AS createdAt`
54
65
  const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
55
66
  const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
56
- // A lookup's derived Fix replaces the one it read, unless one started later
57
- // changed it since: the check time, which only changes stamp
58
- // (github-issue-links.ts issueUpdates), orders them.
59
- const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
67
+ // A lookup's derived Fix and duplicate replace those it read, unless one
68
+ // started later changed them since: the check time, which only changes
69
+ // stamp (github-issue-links.ts issueUpdates), orders them. The issue's own
70
+ // URL is never written here.
71
+ const tracking = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, duplicate_of_url = ?, auto_fix_checked_at = ?
60
72
  WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
73
+ AND COALESCE(duplicate_of_url, '') = COALESCE(?, '')
61
74
  AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
62
75
  const claim = db.prepare(`INSERT INTO managed_finding_issue (finding_id, repo_id, repository, request_id, created_by, created_at)
63
76
  VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (finding_id) DO NOTHING`)
@@ -66,8 +79,9 @@ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
66
79
  return {
67
80
  async getManagedIssue(findingId) { return (await get.get(findingId) as ManagedIssue | undefined) ?? null },
68
81
  async listManagedIssues(ids) { return await list.all(JSON.stringify(ids)) as ManagedIssue[] },
69
- async setManagedIssueAutoFix(findingId, issueUrl, previous, next, checkedAt) {
70
- return Number((await autoFix.run(next, checkedAt, findingId, issueUrl, previous, checkedAt)).changes) > 0
82
+ async setManagedIssueTracking(findingId, issueUrl, previous, next, checkedAt) {
83
+ return Number((await tracking.run(next.autoFix, next.duplicateOf, checkedAt, findingId, issueUrl,
84
+ previous.autoFix, previous.duplicateOf, checkedAt)).changes) > 0
71
85
  },
72
86
  async claimManagedIssue(issue) {
73
87
  return Number((await claim.run(issue.findingId, issue.repoId, issue.repository, issue.requestId, issue.createdBy, issue.createdAt)).changes) > 0
@@ -286,10 +286,11 @@ export async function npmCommitTags(repo: string, sha: string, version: string,
286
286
  // An advisory on the package, with the versions it covers as their indexes in
287
287
  // the list it was asked with: npm's (`registry`), or one its repository
288
288
  // publishes on GitHub before GitHub reviews it into npm's (`repository`); its
289
- // text (markdown) where its source has one.
289
+ // text (markdown) where its source has one; `malware` where upstream tells it
290
+ // is of malware, any of its rows.
290
291
  export interface NpmAdvisory {
291
292
  id: string; source: Advisory['source']; ghsa?: string; url?: string; title?: string; severity?: string; cvss?: number; cwe: string[]; range?: string
292
- details?: string; affected: number[]
293
+ details?: string; malware?: true; affected: number[]
293
294
  }
294
295
 
295
296
  // `versions` those `affected` indexes; `repository` false where the
@@ -377,7 +378,7 @@ async function askAdvisories(name: string, versions: string[], { githubToken, re
377
378
  const own: NpmAdvisory = {
378
379
  id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
379
380
  ...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
380
- ...row.details && { details: row.details }, affected,
381
+ ...row.details && { details: row.details }, ...row.malware && { malware: true }, affected,
381
382
  }
382
383
  const known = merged.get(row.id)
383
384
  if (!known) {
@@ -227,3 +227,7 @@ export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): Kept
227
227
  // what their reviews are held to.
228
228
  export const lockfileReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles', debug = false): KeptStore<T> =>
229
229
  keptStore<T>(db, `repository/review-${type}`, HISTORY_PAGE_KEPT_MS, debug)
230
+ // A bundle's validations (bundle-validation.ts), by the bundle, where it is,
231
+ // what its reader could check and the rules they were made by: kept a month
232
+ // after they were made, as what they check never changes.
233
+ export const bundleValidationStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'bundles/validations', HISTORY_PAGE_KEPT_MS, debug)
@@ -1,5 +1,7 @@
1
1
  import { Buffer } from 'node:buffer'
2
+ import { buildableEntry } from './bundle-build.ts'
2
3
  import { normalizeTeamPath } from './repo-path.ts'
4
+ import { MAX_STASIS_CONFIG_BYTES, parseStasisConfig } from './stasis-config.ts'
3
5
 
4
6
  export const MAX_PACKAGE_BYTES = 256 * 1024
5
7
  const MAX_ENTRY_POINTS = 100
@@ -33,19 +35,45 @@ export function packageEntryPoints(manifest: unknown, directory: string): string
33
35
  return [...paths]
34
36
  }
35
37
 
38
+ // The entries a stasis.config.json in `directory` lists, as repository paths:
39
+ // the ones a server build takes, which it builds with their options there.
40
+ export function configEntryPoints(text: string, directory: string): string[] {
41
+ const paths: string[] = []
42
+ for (const key of parseStasisConfig(text, directory) ?? []) {
43
+ const path = directory ? `${directory}/${key}` : key
44
+ if (buildableEntry(path) && paths.length < MAX_ENTRY_POINTS) paths.push(path)
45
+ }
46
+ return paths
47
+ }
48
+
36
49
  // Use the immutable blob from the authorized listing, never download_url or a
37
- // resolved symlink target. Malformed or oversized manifests are optional.
50
+ // resolved symlink target. A malformed or oversized blob is none (null).
51
+ async function readBlobText(sha: string, read: (suffix: string) => Promise<unknown>, maxBytes: number): Promise<string | null> {
52
+ const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
53
+ if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
54
+ || blob.size < 0 || blob.size > maxBytes || blob.content.length > maxBytes * 2) return null
55
+ const bytes = Buffer.from(blob.content, 'base64')
56
+ return bytes.length !== blob.size || bytes.length > maxBytes ? null : bytes.toString('utf8')
57
+ }
58
+
59
+ // Manifests are optional: suggestions must not prevent browsing. The handler
60
+ // still rechecks GitHub and managed access after these optional upstream reads.
38
61
  export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
39
62
  try {
40
- const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
41
- if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
42
- || blob.size < 0 || blob.size > MAX_PACKAGE_BYTES || blob.content.length > MAX_PACKAGE_BYTES * 2) return []
43
- const bytes = Buffer.from(blob.content, 'base64')
44
- if (bytes.length !== blob.size || bytes.length > MAX_PACKAGE_BYTES) return []
45
- return packageEntryPoints(JSON.parse(bytes.toString('utf8')), directory)
63
+ const text = await readBlobText(sha, read, MAX_PACKAGE_BYTES)
64
+ return text === null ? [] : packageEntryPoints(JSON.parse(text), directory)
65
+ } catch {
66
+ return []
67
+ }
68
+ }
69
+
70
+ // A stasis.config.json whose entries Stasis can't tell lists none here;
71
+ // building them reports why (config-invalid), as it does options Stasis refuses.
72
+ export async function readConfigEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
73
+ try {
74
+ const text = await readBlobText(sha, read, MAX_STASIS_CONFIG_BYTES)
75
+ return text === null ? [] : configEntryPoints(text, directory)
46
76
  } catch {
47
- // Suggestions must not prevent browsing. The handler still rechecks
48
- // GitHub and managed access after this optional upstream read.
49
77
  return []
50
78
  }
51
79
  }
@@ -13,7 +13,7 @@ import { brotliDecompress } from 'node:zlib'
13
13
  import { format } from '@preventive/compare/pretty.js'
14
14
  import { computeFileHash } from '@preventive/report'
15
15
  import { FILE_HASH, computeBundleFileHashes } from '../common/bundle-metadata.js'
16
- import { bundleCompareFiles } from '../common/bundle-compare-inputs.js'
16
+ import { bundleCompareFiles, bundleCompareSideScopes } from '../common/bundle-compare-inputs.js'
17
17
  import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
18
18
  import { MAX_PRETTY_BYTES, needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
19
19
  import type { OpenedBlob } from './blob-store.ts'
@@ -413,12 +413,14 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
413
413
  // before this resolves: for the files that changed between them
414
414
  // pretty-printed (compared), and their diffs with renamed names hidden
415
415
  // (rename-diff.ts renamedPairs); a bundle's within a reason `scope`
416
- // where one is asked for, as Compare reads it then.
417
- bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
418
- const sideOf = async (record: ManagedBundle, side: Side): Promise<ComparedSide> => ({ side, ...await bundleSide(await bundleDetails(record), scope),
416
+ // where one is asked for, as Compare reads it then (bundleCompareSideScopes).
417
+ async bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
418
+ const [baseDetails, otherDetails] = await Promise.all([bundleDetails(base), bundleDetails(other)])
419
+ const [baseScope, otherScope] = bundleCompareSideScopes(baseDetails, otherDetails, scope)
420
+ const sideOf = async (record: ManagedBundle, details: typeof baseDetails, side: Side, within: string): Promise<ComparedSide> => ({ side, ...await bundleSide(details, within),
419
421
  kept: request => kept(() => bundles.open(record.id, prettyFile(request))),
420
422
  made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
421
- return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
423
+ return Promise.all([sideOf(base, baseDetails, 'base', baseScope), sideOf(other, otherDetails, 'other', otherScope)])
422
424
  },
423
425
  // A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
424
426
  bundleDetails,
@@ -12,7 +12,7 @@ import { triageWireEntry } from './triage-response.ts'
12
12
  import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
13
13
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
14
14
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
15
- import { auditCache, auditedRepos } from './upstream-cache.ts'
15
+ import { auditCache, auditedKeys } from './upstream-cache.ts'
16
16
  import { serveTeamFeed } from './team-feed.ts'
17
17
  import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
18
18
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
@@ -207,7 +207,7 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
207
207
  const repoAdvisories = url.searchParams.get('repoAdvisories') === 'true'
208
208
  const result = await fetchBundleAdvisories(inventory.packages, signal, {
209
209
  debug: deps.config.debug, repoAdvisories, details: url.searchParams.get('details') === 'true',
210
- cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
210
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedKeys(inventory.packages, repoAdvisories)),
211
211
  })
212
212
  await recheck()
213
213
  json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
@@ -111,7 +111,8 @@ const isCommit = (text: string) => /^[\da-f]{40}$/u.test(text)
111
111
 
112
112
  // `source`, what it answers of a public repository kept for every scan, the
113
113
  // same whoever asks: of a commit while kept (but none, as one can be pushed
114
- // later), the rest for RISK_KEPT_MS. A private one's, or one missing, never.
114
+ // later), the rest, its advisories too, for RISK_KEPT_MS. A private one's,
115
+ // or one missing, never.
115
116
  export function sharedSource(source: ActionsSource, answers: ActionAnswers): ActionsSource {
116
117
  const keyOf = (what: string, name: string, ...more: string[]) => [what, name.toLowerCase(), ...more].join('\0')
117
118
  const kept = (key: string, lasting = false) => {
@@ -140,6 +141,8 @@ export function sharedSource(source: ActionsSource, answers: ActionAnswers): Act
140
141
  branches: name => shared(name, keyOf('branches', name), false, () => source.branches(name)),
141
142
  contains: (name, base, sha) => shared(name, keyOf('contains', name, base, sha), isCommit(base), () => source.contains(name, base, sha)),
142
143
  file: (name, path, commit) => shared(name, keyOf('file', name, path, commit), isCommit(commit), () => source.file(name, path, commit)),
144
+ ...source.advisories && { advisories: (name: string, versions: string[], internal: boolean) =>
145
+ shared(name, keyOf('advisories', name, ...versions), false, () => source.advisories!(name, versions, internal)) },
143
146
  }
144
147
  }
145
148
 
@@ -153,8 +156,8 @@ export interface ActionsResolving {
153
156
  }
154
157
 
155
158
  // A reference's line: kept (`stored`, as read of the store with others),
156
- // else resolved, and kept where it is a public repository's; why not, where
157
- // it couldn't be.
159
+ // else resolved, and kept where it is a public repository's and its
160
+ // advisories could be had; why not, where it couldn't be.
158
161
  async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef,
159
162
  stored = resolving.store?.read(key)): Promise<ActionLine | null> {
160
163
  const kept = resolving.memory.get(key) ?? await stored?.then(row => row && { at: row.at, line: row.value })
@@ -164,7 +167,7 @@ async function resolved(resolving: ActionsResolving, source: ActionsSource, { ke
164
167
  let found: ResolvedAction
165
168
  try { found = await resolveAction(uses, source, { owner }) } catch (error) { return resolving.signal.aborted ? null : { type: 'action', key, ...failureOf(error) } }
166
169
  const line: ActionLine = { type: 'action', ...found }
167
- if (found.repository?.private === false) {
170
+ if (found.repository?.private === false && !found.findings.some(({ id }) => id === 'advisories-unavailable')) {
168
171
  const at = Date.now()
169
172
  resolving.memory.set(key, { at, line })
170
173
  await resolving.store?.write(key, line, at)
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto'
2
2
  import type { ManagedConfig } from './config.ts'
3
3
  import type { SelectedRepo } from './db.ts'
4
4
  import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, onTokenRejected, repoAccessToken } from './github-app.ts'
5
- import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
5
+ import { MAX_PACKAGE_BYTES, readConfigEntryPoints, readPackageEntryPoints } from './package-entry-points.ts'
6
+ import { MAX_STASIS_CONFIG_BYTES, STASIS_CONFIG } from './stasis-config.ts'
6
7
  import { readSolidityEntryPoints } from './solidity-entry-points.ts'
7
8
  import { parseGithubCommit } from './bundle-commits.ts'
8
9
  import { appDependabotAlerts } from './dependabot-access.ts'
@@ -279,9 +280,15 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
279
280
  const type = item.submodule_git_url ? 'submodule' : item.type
280
281
  if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
281
282
  }
282
- const manifestPath = path ? `${path}/package.json` : 'package.json'
283
- const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
284
- && typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
283
+ const listed = (name: string, maxBytes: number) => data.find(item => item?.path === (path ? `${path}/${name}` : name) && item.name === name
284
+ && item.type === 'file' && !item.submodule_git_url && typeof item.size === 'number' && item.size >= 0 && item.size <= maxBytes
285
+ && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
286
+ // The entries stasis.config.json lists are suggested before any guess:
287
+ // a build takes them alone, with their options there.
288
+ const config = listed(STASIS_CONFIG, MAX_STASIS_CONFIG_BYTES)
289
+ const configEntryPoints = config ? await readConfigEntryPoints(config.sha, path, read) : []
290
+ if (configEntryPoints.length > 0) return { entries, limited: data.length >= 1000, configEntryPoints }
291
+ const manifest = listed('package.json', MAX_PACKAGE_BYTES)
285
292
  // Both optional suggestions, read together: neither fails, each
286
293
  // answering none where its reads do.
287
294
  const [packageEntryPoints, solidity] = await Promise.all([manifest ? readPackageEntryPoints(manifest.sha, path, read) : undefined,
@@ -242,8 +242,8 @@ async function repositoryHeads(owner: string, name: string, token: string, fetch
242
242
  // Histories read in a GraphQL query, and queries at once: each counts its
243
243
  // commits' lines, which GitHub works out as it answers, stopping a query
244
244
  // past ten seconds.
245
- const PAGES_PER_QUERY = 5
246
- const PAGES_PARALLEL = 5
245
+ export const PAGES_PER_QUERY = 5
246
+ export const PAGES_PARALLEL = 5
247
247
 
248
248
  // The first page of the history from each of `tops`, by its top, each told
249
249
  // to `keep` as it is read, so those read stay kept where another fails: with
@@ -16,6 +16,7 @@ import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
16
16
  import { GithubApiError, githubJson } from './github-app.ts'
17
17
  import { shareGithubRead } from './github-requests.ts'
18
18
  import { coauthorsOf } from './commit-coauthors.ts'
19
+ import { windowsOn } from './history-windows.ts'
19
20
  import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
20
21
 
21
22
  export const PULSE_WEEKS = [1, 4, 13, 52]
@@ -222,6 +223,7 @@ export async function yearCommits(fullName: string, token: string, head: string,
222
223
  const read = () => [...found.values()].filter(dated).length
223
224
  const passed = () => [...found.values()].filter(commit => !dated(commit) && within(commit))
224
225
  let queries = 0
226
+ let windowed = false
225
227
  let starts = [head]
226
228
  while (starts.length > 0) {
227
229
  // Those not read yet, the head before any, else the parents of those
@@ -232,9 +234,16 @@ export async function yearCommits(fullName: string, token: string, head: string,
232
234
  .filter(commit => commit.parents.some(sha => unread.has(sha))).map(commit => commit.committedAt ?? Number.POSITIVE_INFINITY))
233
235
  if (need > since && from < need) return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from }
234
236
  if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
235
- const batch = starts.slice(0, YEAR_STARTS)
237
+ // The pages kept read first; once past them, the rest read at once by
238
+ // when its commits were made (history-windows.ts windowsOn), as far back
239
+ // as it needs, the year's as far as its commits are followed.
240
+ const next: Awaited<ReturnType<typeof windowsOn>> | null = windowed || found.size === 0 ? null
241
+ : await windowsOn(fullName, token, head, starts, found, need > since ? need : since - YEAR_SKEW_MS, from, pages, fetchImpl, queries)
242
+ windowed ||= next?.windowed === true
243
+ queries += next?.queries ?? 0
244
+ const batch = (next?.batch ?? starts).slice(0, YEAR_STARTS)
236
245
  for (const sha of batch) tried.add(sha)
237
- const got = await historyFrom(fullName, token, batch, pages, fetchImpl)
246
+ const got = batch.length > 0 ? await historyFrom(fullName, token, batch, pages, fetchImpl) : { commits: [], read: 0 }
238
247
  if (got.read > 0) queries++
239
248
  for (const commit of got.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
240
249
  told(read())
@@ -1,19 +1,18 @@
1
1
  // A lockfile's advisories, as a repository risk scan asks them
2
2
  // (repository-risk.ts): npm's registry's of its packages of the registry or
3
3
  // a mirror, asked at once, as `npm audit` asks, each told with whether it is
4
- // of malware (risk's npmMalwareAdvisory); kept by the lockfile's blob, in the
5
- // instance's memory and the source's `audits`, for RISK_KEPT_MS, as npm
4
+ // of malware, as upstream tells (`malware`); kept by the lockfile's blob, in
5
+ // the instance's memory and the source's `audits`, for RISK_KEPT_MS, as npm
6
6
  // publishes advisories, or why they couldn't be had for AUDIT_FAILED_KEPT_MS.
7
7
  import type { Advisory } from '@preventive/upstream/advisories.js'
8
8
  import { isExactVersion } from '@preventive/upstream/semver.js'
9
- import { npmMalwareAdvisory } from '../risk/index.js'
10
9
  import { isNpmPackageName } from '../common/managed/npm-packages.js'
11
10
  import type { LockedPackage } from './locked-packages.ts'
12
11
  import type { Lru } from './lru.ts'
13
12
  import type { KeptStore } from './npm-kept.ts'
14
13
  import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
15
14
 
16
- export type AdvisoryRow = Advisory & { malware: boolean }
15
+ export type AdvisoryRow = Omit<Advisory, 'malware'> & { malware: boolean }
17
16
  // A lockfile's advisories, or why they couldn't be had.
18
17
  export type Audit = AdvisoryRow[] | RiskFailure
19
18
 
@@ -67,7 +66,7 @@ export async function auditLockfile(source: AuditSource, memory: Lru<{ at: numbe
67
66
  let audit: Audit
68
67
  try {
69
68
  audit = (await source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), signal))
70
- .map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
69
+ .map(advisory => ({ ...advisory, malware: advisory.malware === true }))
71
70
  } catch (error) {
72
71
  if (signal.aborted) return null
73
72
  audit = failureOf(error)
@@ -18,8 +18,8 @@
18
18
  // that locks them (LockedPackage `dev`) once all the rest are, as an install
19
19
  // of no devDependencies leaves them out. A lockfile's packages of npm's
20
20
  // registry or a mirror are asked of npm's advisories all at once, as `npm
21
- // audit` asks, each advisory told with whether it is of malware (risk's
22
- // npmMalwareAdvisory), which alone is a supply chain's risk; the rest are
21
+ // audit` asks, each advisory told with whether it is of malware (upstream's
22
+ // `malware`), which alone is a supply chain's risk; the rest are
23
23
  // vulnerabilities, told beside. A repository's Dependabot alerts of npm's, open or dismissed,
24
24
  // are told once its lockfiles' advisories are, each with the versions its
25
25
  // lockfile locks that it covers, for the page to blend them into npm's; one
@@ -94,8 +94,8 @@ export interface RepositoryRiskSource<R extends RepositoryRiskRepo> extends Audi
94
94
 
95
95
  // A Dependabot alert as a scan tells it: with the versions of its package
96
96
  // the lockfile it is of locks that it covers, none where it is of none read
97
- // (a package.json, bun's lockfile), and whether it is of malware, as an
98
- // advisory's is told.
97
+ // (a package.json, bun's lockfile), and whether it is of malware, by the rule
98
+ // upstream tells an advisory's by (risk's npmMalwareAdvisory).
99
99
  export type DependabotAlertRow = DependabotAlert & { versions: string[]; malware: boolean }
100
100
 
101
101
  // A risk line is of npm's package of its name and version, or, with an
@@ -26,12 +26,13 @@ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'g
26
26
  'listTriageFixes', 'getRepoDependencyReviews', 'listTeamDependencyReviews', 'getNpmReviewReader',
27
27
  'listDependencyReviewTeams', 'listDependencyReviewEnabledTeams', 'listUserNpmScopes', 'listTeamNpmScopes', 'hasTeamNpmScope',
28
28
  'listGithubMetadata', 'listGithubRepositoryVisibility', 'setGithubMetadata', 'recordGithubMetadataAttempts', 'setGithubRepositoryVisibility',
29
- 'listLinkReports', 'listLegacyLinkReports', 'listReportFilenamesWithBundleHash', 'listReportsForRepo', 'listBundlesForRepo',
29
+ 'listLinkReports', 'listLegacyLinkReports', 'listReportFilenamesWithBundleHash', 'listReportsForBundle', 'listReportsForRepo', 'listBundlesForRepo',
30
30
  'listFindingCatalogReports', 'userCanReadBundleAdvisories',
31
31
  'sessionWithUser', 'getTeamReportAccessSnapshot', 'getReportAccessSnapshot', 'getBundleAccessSnapshot', 'getReportView', 'getUserGithubId', 'getUserTeamFeedSnapshot', 'getAnnotations', 'getAnnotationRevision', 'getUserTokens',
32
32
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
33
33
  'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
34
34
  'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'getFirstUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
35
+ 'getTeamAppSummaries', 'setTeamAppSummary', 'deleteTeamAppSummaries',
35
36
  'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
36
37
  // Session presence and upload keys change only their own rows.
37
38
  const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
@@ -0,0 +1,46 @@
1
+ // stasis.config.json's `entries`, as Stasis reads them (src/cmd/bundle-options.js's
2
+ // readConfigEntries, which is not a public package export): the entries it
3
+ // names, each a path relative to the file's directory. Their options are
4
+ // Stasis's: buildGitHubBundle builds the entries the stasis.config.json in its
5
+ // directory names with their options there, and refuses what it can't build.
6
+ export const STASIS_CONFIG = 'stasis.config.json'
7
+ export const MAX_STASIS_CONFIG_BYTES = 256 * 1024
8
+
9
+ const plainObject = (value: unknown): value is Record<string, unknown> =>
10
+ value !== null && typeof value === 'object' && !Array.isArray(value)
11
+
12
+ // `entry` as a path from `directory` ('.' for it), as `stasis bundle` run
13
+ // there resolves it; null where that is outside it.
14
+ export function configEntryKey(directory: string, entry: string): string | null {
15
+ if (entry.startsWith('/')) return null
16
+ const base = directory ? directory.split('/') : []
17
+ const parts = [...base]
18
+ for (const part of entry.split('/')) {
19
+ if (part === '' || part === '.') continue
20
+ if (part !== '..') parts.push(part)
21
+ else if (parts.pop() === undefined) return null
22
+ }
23
+ if (base.some((part, i) => parts[i] !== part)) return null
24
+ return parts.slice(base.length).join('/') || '.'
25
+ }
26
+
27
+ // The entries the stasis.config.json in `directory` whose text is `text`
28
+ // names, each as a path from there; null where it has no `entries`. One whose
29
+ // entries Stasis can't tell throws.
30
+ export function parseStasisConfig(text: string, directory = ''): Set<string> | null {
31
+ const file = directory ? `${directory}/${STASIS_CONFIG}` : STASIS_CONFIG
32
+ let config: unknown
33
+ try { config = JSON.parse(text) } catch (cause) { throw new Error(`${file} is not valid JSON`, { cause }) }
34
+ const entries = plainObject(config) ? config['entries'] : undefined
35
+ if (entries === undefined) return null
36
+ if (!plainObject(entries)) throw new Error(`${file}: entries must be an object of each entry's stasis bundle options`)
37
+ const named = new Map<string, string>()
38
+ for (const entry of Object.keys(entries)) {
39
+ const what = `${file}: entries ${JSON.stringify(entry)}`
40
+ const key = configEntryKey(directory, entry)
41
+ if (key === null) throw new Error(`${what}: an entry must be a path relative to the directory of ${STASIS_CONFIG}, within it`)
42
+ if (named.has(key)) throw new Error(`${what} names the same entry as ${JSON.stringify(named.get(key))}`)
43
+ named.set(key, entry)
44
+ }
45
+ return new Set(named.keys())
46
+ }
@@ -0,0 +1,54 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedSql } from './sql.ts'
3
+
4
+ // Each team's catalog summary (team-app.ts), kept for every instance: working
5
+ // one out builds the team's whole workspace, which an instance would otherwise
6
+ // do for the first catalog it serves. Keyed by the catalog's own content key
7
+ // (team-app.ts teamAppKey), hashed: the published reports, their content and
8
+ // what the reader is allowed of each (its role, its dependency and security
9
+ // permissions, the team's scopes and links), so readers whose workspaces
10
+ // differ never share one. A change to any of them is a new key, as is a new
11
+ // deployment; one no longer asked for goes once it is old (index.ts reap).
12
+ export const TEAM_APP_SUMMARY_SCHEMA = `
13
+ CREATE TABLE IF NOT EXISTS managed_team_app_summary (
14
+ summary_key TEXT PRIMARY KEY,
15
+ summary TEXT NOT NULL,
16
+ stored_at INTEGER NOT NULL
17
+ ) STRICT;
18
+ `
19
+
20
+ export interface TeamAppSummaryStore {
21
+ // Hashed key (teamAppSummaryKey) → summary for those of `keys` kept.
22
+ getTeamAppSummaries(keys: string[]): Promise<Map<string, string>>
23
+ setTeamAppSummary(key: string, summary: string, storedAt: number): Promise<void>
24
+ // Removes those stored before `before`; how many.
25
+ deleteTeamAppSummaries(before: number): Promise<number>
26
+ }
27
+
28
+ // How a summary is worked out (common/workspace-app.js, team-app.js), which a
29
+ // deployment may change: one stored by another deployment, or before this
30
+ // is raised, is worked out again.
31
+ const SUMMARY_VERSION = 1
32
+ const DEPLOYMENT = process.env['VERCEL_DEPLOYMENT_ID'] ?? null
33
+
34
+ export const teamAppSummaryKey = (key: string): string => createHash('sha256').update(JSON.stringify([SUMMARY_VERSION, DEPLOYMENT, key])).digest('base64url')
35
+
36
+ export function teamAppSummaryMethods(db: ManagedSql): TeamAppSummaryStore {
37
+ const getMany = db.prepare('SELECT summary_key, summary FROM managed_team_app_summary WHERE summary_key IN (SELECT value FROM json_each(?))')
38
+ const set = db.prepare(`INSERT INTO managed_team_app_summary (summary_key, summary, stored_at) VALUES (?, ?, ?)
39
+ ON CONFLICT (summary_key) DO UPDATE SET summary = excluded.summary, stored_at = excluded.stored_at`)
40
+ const remove = db.prepare('DELETE FROM managed_team_app_summary WHERE stored_at < ?')
41
+ return {
42
+ async getTeamAppSummaries(keys) {
43
+ if (keys.length === 0) return new Map()
44
+ const rows = await getMany.all(JSON.stringify(keys)) as { summary_key: string; summary: string }[]
45
+ return new Map(rows.map(row => [row.summary_key, row.summary]))
46
+ },
47
+ async setTeamAppSummary(key, summary, storedAt) {
48
+ await set.run(key, summary, storedAt)
49
+ },
50
+ async deleteTeamAppSummaries(before) {
51
+ return Number((await remove.run(before)).changes)
52
+ },
53
+ }
54
+ }