trackfw 6.10.0 → 7.0.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/README.md +0 -3
- package/package.json +1 -1
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +12 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +69 -0
- package/src/commands/index.js +22 -4
- package/src/commands/integrations.js +7 -0
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +3 -1
- package/src/commands/update.js +3 -1
- package/src/commit/runner.js +352 -0
- package/src/config/index.js +27 -0
- package/src/generators/hooks.js +479 -2
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +1 -19
- package/src/i18n/locales/es-ES.json +1 -19
- package/src/i18n/locales/pt-BR.json +1 -19
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +15 -2
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +213 -26
- package/src/thirdparty/fetch.js +140 -0
- package/src/thirdparty/markers.js +207 -0
- package/src/thirdparty/provenance.js +114 -0
- package/src/thirdparty/quarantine.js +113 -0
- package/src/thirdparty/references.js +194 -0
- package/src/validator/index.js +715 -16
- package/src/commands/plugins.js +0 -184
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of internal/thirdparty/fetch.go — the network fetch primitive
|
|
4
|
+
// used by the two-phase third-party artifact gate (skills/agents installed
|
|
5
|
+
// from a URL). See:
|
|
6
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
|
|
7
|
+
// (D7).
|
|
8
|
+
|
|
9
|
+
const https = require('node:https')
|
|
10
|
+
const { URL } = require('node:url')
|
|
11
|
+
|
|
12
|
+
// MAX_CONTENT_SIZE is the network fetch cap for third-party text artifacts
|
|
13
|
+
// (D7): 2 MiB — mirrors internal/thirdparty/fetch.go:maxContentSize.
|
|
14
|
+
const MAX_CONTENT_SIZE = 2 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
// MAX_REDIRECTS mirrors internal/thirdparty/fetch.go:maxRedirects (3). Note
|
|
17
|
+
// the Go reference's own net/http.Client.CheckRedirect semantics: the check
|
|
18
|
+
// runs BEFORE following a redirect and compares the count of requests
|
|
19
|
+
// ALREADY COMPLETED (via.length, which includes the request that just
|
|
20
|
+
// returned the redirect) against maxRedirects — so in practice only
|
|
21
|
+
// (maxRedirects - 1) redirects are ever followed before the next one is
|
|
22
|
+
// refused. Reproduced here byte-for-byte via requestsCompleted, not
|
|
23
|
+
// "fixed" to what the doc comment alone might suggest.
|
|
24
|
+
const MAX_REDIRECTS = 3
|
|
25
|
+
|
|
26
|
+
const TIMEOUT_MS = 30 * 1000
|
|
27
|
+
|
|
28
|
+
// ALLOWED_CONTENT_TYPES mirrors internal/thirdparty/fetch.go:allowedContentTypes.
|
|
29
|
+
const ALLOWED_CONTENT_TYPES = new Set(['text/plain', 'text/markdown', 'text/x-markdown'])
|
|
30
|
+
|
|
31
|
+
// contentTypeAllowed mirrors internal/thirdparty/fetch.go:contentTypeAllowed
|
|
32
|
+
// — tolerates an optional "; charset=..." (or any other parameter) suffix.
|
|
33
|
+
function contentTypeAllowed(contentType) {
|
|
34
|
+
const raw = String(contentType || '')
|
|
35
|
+
const semi = raw.indexOf(';')
|
|
36
|
+
const base = (semi >= 0 ? raw.slice(0, semi) : raw).trim().toLowerCase()
|
|
37
|
+
return ALLOWED_CONTENT_TYPES.has(base)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// api is the module.exports object itself, referenced internally via
|
|
41
|
+
// `api.requestOnce(...)` so tests can substitute the single HTTP round
|
|
42
|
+
// trip without a real TLS socket — mirrors the thirdPartyFetch
|
|
43
|
+
// package-var substitution pattern already used at the command layer
|
|
44
|
+
// (npm/src/commands/thirdparty.js). Added in ML-4C to close a coverage
|
|
45
|
+
// gap hefesto-tf found: the resp.statusCode !== 200 branch below existed
|
|
46
|
+
// but was never exercised by any test.
|
|
47
|
+
const api = {}
|
|
48
|
+
|
|
49
|
+
function requestOnce(rawURL) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const req = https.get(rawURL, { timeout: TIMEOUT_MS }, res => {
|
|
52
|
+
const chunks = []
|
|
53
|
+
let total = 0
|
|
54
|
+
let tooLarge = false
|
|
55
|
+
res.on('data', chunk => {
|
|
56
|
+
if (tooLarge) return
|
|
57
|
+
total += chunk.length
|
|
58
|
+
if (total > MAX_CONTENT_SIZE) {
|
|
59
|
+
tooLarge = true
|
|
60
|
+
res.destroy()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
chunks.push(chunk)
|
|
64
|
+
})
|
|
65
|
+
res.on('end', () => {
|
|
66
|
+
resolve({ statusCode: res.statusCode, headers: res.headers, body: Buffer.concat(chunks), tooLarge })
|
|
67
|
+
})
|
|
68
|
+
res.on('error', err => reject(new Error(`failed to read response body: ${err.message}`)))
|
|
69
|
+
})
|
|
70
|
+
req.on('timeout', () => req.destroy(new Error('fetch failed: request timed out after 30s')))
|
|
71
|
+
req.on('error', err => reject(new Error(`fetch failed: ${err.message}`)))
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// fetchOnce follows redirects with the same accounting as Go's
|
|
76
|
+
// fetchClient.CheckRedirect (see MAX_REDIRECTS doc above). requestsCompleted
|
|
77
|
+
// starts at 0 and is incremented after every completed HTTP request.
|
|
78
|
+
async function fetchOnce(rawURL, requestsCompleted) {
|
|
79
|
+
let parsed
|
|
80
|
+
try {
|
|
81
|
+
parsed = new URL(rawURL)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`invalid URL "${rawURL}": ${err.message}`)
|
|
84
|
+
}
|
|
85
|
+
if (parsed.protocol !== 'https:') {
|
|
86
|
+
throw new Error(`refused: URL scheme must be https, got "${parsed.protocol.replace(/:$/, '')}"`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const res = await api.requestOnce(rawURL)
|
|
90
|
+
requestsCompleted += 1
|
|
91
|
+
|
|
92
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
93
|
+
if (requestsCompleted >= MAX_REDIRECTS) {
|
|
94
|
+
throw new Error(`fetch failed: stopped after ${MAX_REDIRECTS} redirects`)
|
|
95
|
+
}
|
|
96
|
+
let next
|
|
97
|
+
try {
|
|
98
|
+
next = new URL(res.headers.location, rawURL).toString()
|
|
99
|
+
} catch (err) {
|
|
100
|
+
throw new Error(`invalid redirect location: ${err.message}`)
|
|
101
|
+
}
|
|
102
|
+
const nextScheme = new URL(next).protocol
|
|
103
|
+
if (nextScheme !== 'https:') {
|
|
104
|
+
throw new Error(`redirect to non-https URL refused: ${next}`)
|
|
105
|
+
}
|
|
106
|
+
return fetchOnce(next, requestsCompleted)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (res.statusCode !== 200) {
|
|
110
|
+
throw new Error(`fetch failed: HTTP ${res.statusCode} for ${rawURL}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const contentType = res.headers['content-type']
|
|
114
|
+
if (!contentTypeAllowed(contentType)) {
|
|
115
|
+
throw new Error(`refused: unsupported Content-Type "${contentType || ''}" (allowed: text/plain, text/markdown, text/x-markdown)`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (res.tooLarge) {
|
|
119
|
+
throw new Error(`refused: content exceeds ${MAX_CONTENT_SIZE} bytes`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return res.body
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// fetch downloads the content at rawURL under the D7 network policy:
|
|
126
|
+
// https-only (validated before the first request), a 30s timeout, at most
|
|
127
|
+
// (MAX_REDIRECTS - 1) redirect hops (each revalidated for https), a 2 MiB
|
|
128
|
+
// size cap, and a Content-Type allowlist. Mirrors
|
|
129
|
+
// internal/thirdparty/fetch.go:Fetch.
|
|
130
|
+
async function fetch(rawURL) {
|
|
131
|
+
return fetchOnce(rawURL, 0)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
api.fetch = fetch
|
|
135
|
+
api.contentTypeAllowed = contentTypeAllowed
|
|
136
|
+
api.requestOnce = requestOnce
|
|
137
|
+
api.MAX_CONTENT_SIZE = MAX_CONTENT_SIZE
|
|
138
|
+
api.MAX_REDIRECTS = MAX_REDIRECTS
|
|
139
|
+
|
|
140
|
+
module.exports = api
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of internal/thirdparty/markers.go — the D3 objective-refusal
|
|
4
|
+
// marker check and the Checksum primitive. See:
|
|
5
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
|
|
6
|
+
// (D3, D6).
|
|
7
|
+
|
|
8
|
+
const crypto = require('node:crypto')
|
|
9
|
+
const { URL } = require('node:url')
|
|
10
|
+
|
|
11
|
+
// LITERAL_MARKERS mirrors internal/thirdparty/markers.go:literalMarkers — an
|
|
12
|
+
// objective, literal tripwire list, not a filter against a competent
|
|
13
|
+
// adversary (see the ADR's "o que este critério NÃO cobre" section).
|
|
14
|
+
const LITERAL_MARKERS = [
|
|
15
|
+
'git authority',
|
|
16
|
+
'mode lock',
|
|
17
|
+
'governance prerequisite',
|
|
18
|
+
'reporting boundary',
|
|
19
|
+
'scope boundary',
|
|
20
|
+
'dispatch contract',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
// HTML_COMMENT_PATTERN matches HTML comments; step 1 of the D3 pipeline
|
|
24
|
+
// NEUTRALIZES them (strips only the delimiters, keeping the inner content
|
|
25
|
+
// in place to be scanned), mirroring internal/thirdparty/markers.go's
|
|
26
|
+
// D3-ter(b) amendment — see neutralizeHTMLComments below.
|
|
27
|
+
const HTML_COMMENT_PATTERN = /<!--([\s\S]*?)-->/g
|
|
28
|
+
|
|
29
|
+
// neutralizeHTMLComments strips only the HTML comment delimiters ("<!--"
|
|
30
|
+
// and "-->"), keeping whatever text was between them in place to be
|
|
31
|
+
// scanned by the later steps of the pipeline. D3-ter(b): the previous
|
|
32
|
+
// wholesale removal contradicted D3's own written justification for this
|
|
33
|
+
// step ("an LLM reads HTML comments in the token stream") — a marker
|
|
34
|
+
// hidden as `<!-- ## Git authority -->` passed clean.
|
|
35
|
+
function neutralizeHTMLComments(text) {
|
|
36
|
+
return text.replace(HTML_COMMENT_PATTERN, '$1')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// FENCE_PREFIX_PATTERN detects a fence-opening/closing line: optional
|
|
40
|
+
// leading whitespace followed by three or more backticks or tildes.
|
|
41
|
+
//
|
|
42
|
+
// Unlike the Go reference — which is forced into an explicit line-scanner
|
|
43
|
+
// because Go's RE2-based regexp package rejects backreferences at runtime
|
|
44
|
+
// (see vault/notes/go-regexp-re2-sem-backreference-fenced-block-removal-2026-08-15.md)
|
|
45
|
+
// — JS RegExp does support backreferences. This port still uses the same
|
|
46
|
+
// explicit line-scanner algorithm as the Go reference (removeFencedBlocks
|
|
47
|
+
// below), NOT because JS requires it, but because the scanner is what
|
|
48
|
+
// implements the actual CommonMark closing rule correctly (same delimiter
|
|
49
|
+
// character, closer repeat count >= opener repeat count, unclosed fence
|
|
50
|
+
// never re-opens a heading read). A single backreference regex here would
|
|
51
|
+
// diverge from the Go reference on: an unclosed fence, a closing run
|
|
52
|
+
// shorter than the opener, and an indented fence — porting the algorithm,
|
|
53
|
+
// not the regex, is what keeps behavior byte-identical to Go.
|
|
54
|
+
const FENCE_PREFIX_PATTERN = /^\s*(```+|~~~+)/
|
|
55
|
+
|
|
56
|
+
// trimChar mirrors Go's strings.Trim(s, cutset) for a single-char cutset:
|
|
57
|
+
// strips ONLY leading/trailing runs of ch, never occurrences in the middle.
|
|
58
|
+
function trimChar(str, ch) {
|
|
59
|
+
let start = 0
|
|
60
|
+
let end = str.length
|
|
61
|
+
while (start < end && str[start] === ch) start++
|
|
62
|
+
while (end > start && str[end - 1] === ch) end--
|
|
63
|
+
return str.slice(start, end)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// removeFencedBlocks strips PROPERLY-CLOSED fenced code blocks (``` or
|
|
67
|
+
// ~~~), step 2 of the D3 pipeline: lines inside a closed fence are not read
|
|
68
|
+
// as headings, otherwise documentation that merely quotes the marker list
|
|
69
|
+
// would be refused by its own criterion. A fence is closed by a line
|
|
70
|
+
// starting with the same delimiter character, with at least as many
|
|
71
|
+
// repeats as the opener — the CommonMark rule.
|
|
72
|
+
//
|
|
73
|
+
// D3-ter(a) amendment: an opener with NO matching closer before EOF is NOT
|
|
74
|
+
// a fence for this check — the buffered lines (including the opener) are
|
|
75
|
+
// replayed as ordinary text instead of being dropped. Before this
|
|
76
|
+
// amendment, an unclosed fence swallowed the rest of the document as
|
|
77
|
+
// "fenced" content, silently hiding any marker after it. Mirrors
|
|
78
|
+
// internal/thirdparty/markers.go:removeFencedBlocks.
|
|
79
|
+
function removeFencedBlocks(text) {
|
|
80
|
+
const lines = text.split('\n')
|
|
81
|
+
const out = []
|
|
82
|
+
let buffered = [] // lines consumed since the current fence opener; replayed verbatim if it never closes
|
|
83
|
+
let closer = '' // fence delimiter run that closes the current block, '' if not in a fence
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (closer === '') {
|
|
86
|
+
const match = FENCE_PREFIX_PATTERN.exec(line)
|
|
87
|
+
if (match) {
|
|
88
|
+
closer = match[1]
|
|
89
|
+
buffered = [line] // keep the opener in case this fence never closes
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
out.push(line)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
// Inside a (possibly-never-closing) fence: buffer the line, then check
|
|
96
|
+
// if it closes the block.
|
|
97
|
+
buffered.push(line)
|
|
98
|
+
const trimmed = line.trim()
|
|
99
|
+
const delimChar = closer[0]
|
|
100
|
+
if (trimmed.startsWith(delimChar.repeat(closer.length)) && trimChar(trimmed, delimChar) === '') {
|
|
101
|
+
closer = ''
|
|
102
|
+
buffered = [] // closed properly: the buffered fenced content is discarded, as before
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (closer !== '') {
|
|
106
|
+
// Reached EOF still "inside" a fence that never closed (D3-ter(a)): not
|
|
107
|
+
// a fence at all — replay every buffered line, including the opener,
|
|
108
|
+
// as ordinary text to be scanned.
|
|
109
|
+
out.push(...buffered)
|
|
110
|
+
}
|
|
111
|
+
return out.join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// HEADING_LINE_PATTERN matches a single, already-collapsed Markdown heading
|
|
115
|
+
// line (level 1-6). Applied per-line after step 5, on text that no longer
|
|
116
|
+
// contains internal runs of whitespace.
|
|
117
|
+
const HEADING_LINE_PATTERN = /^#{1,6}\s+(.*)$/
|
|
118
|
+
|
|
119
|
+
// WHITESPACE_PATTERN collapses runs of internal whitespace, step 5.
|
|
120
|
+
const WHITESPACE_PATTERN = /\s+/g
|
|
121
|
+
|
|
122
|
+
// checkMarkers applies the D3 objective-refusal criterion to content and
|
|
123
|
+
// returns the literal marker names (from LITERAL_MARKERS) that matched as a
|
|
124
|
+
// heading. The normalization pipeline, in fixed order, mirrors
|
|
125
|
+
// internal/thirdparty/markers.go:CheckMarkers exactly:
|
|
126
|
+
// 1. remove HTML comments;
|
|
127
|
+
// 2. remove fenced code blocks (``` and ~~~);
|
|
128
|
+
// 3. NFKC normalize;
|
|
129
|
+
// 4. casefold (String.prototype.toLowerCase(), matching Go's
|
|
130
|
+
// strings.ToLower — not a true Unicode casefold, deliberately, to stay
|
|
131
|
+
// byte-identical to the Go reference);
|
|
132
|
+
// 5. collapse internal whitespace + strip, applied per line;
|
|
133
|
+
// 6. match only lines matching ^#{1,6}\s+ against the literal marker list.
|
|
134
|
+
//
|
|
135
|
+
// content may be a Buffer or a string; Buffers are decoded as UTF-8,
|
|
136
|
+
// mirroring Go's string(content) over raw bytes.
|
|
137
|
+
function checkMarkers(content) {
|
|
138
|
+
let text = Buffer.isBuffer(content) ? content.toString('utf8') : String(content)
|
|
139
|
+
|
|
140
|
+
// 1. Neutralize HTML comments — strip only the delimiters, keep the
|
|
141
|
+
// inner content in place to be scanned (D3-ter(b)).
|
|
142
|
+
text = neutralizeHTMLComments(text)
|
|
143
|
+
|
|
144
|
+
// 2. Remove fenced code blocks — lines inside a fence are not headings.
|
|
145
|
+
text = removeFencedBlocks(text)
|
|
146
|
+
|
|
147
|
+
// 3. NFKC normalize.
|
|
148
|
+
text = text.normalize('NFKC')
|
|
149
|
+
|
|
150
|
+
// 4. Casefold.
|
|
151
|
+
text = text.toLowerCase()
|
|
152
|
+
|
|
153
|
+
const matched = []
|
|
154
|
+
const seen = new Set()
|
|
155
|
+
for (const line of text.split('\n')) {
|
|
156
|
+
// 5. Collapse internal whitespace + strip.
|
|
157
|
+
const collapsed = line.replace(WHITESPACE_PATTERN, ' ').trim()
|
|
158
|
+
|
|
159
|
+
// 6. Match only heading lines against the literal marker list.
|
|
160
|
+
const match = HEADING_LINE_PATTERN.exec(collapsed)
|
|
161
|
+
if (!match) continue
|
|
162
|
+
const body = match[1]
|
|
163
|
+
for (const marker of LITERAL_MARKERS) {
|
|
164
|
+
if (body === marker && !seen.has(marker)) {
|
|
165
|
+
matched.push(marker)
|
|
166
|
+
seen.add(marker)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return matched
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// checksum returns the SHA-256 hex digest of the raw bytes, before any
|
|
174
|
+
// normalization. Mirrors internal/thirdparty/markers.go:Checksum. raw may be
|
|
175
|
+
// a Buffer or a string (encoded as UTF-8).
|
|
176
|
+
function checksum(raw) {
|
|
177
|
+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'utf8')
|
|
178
|
+
return crypto.createHash('sha256').update(buf).digest('hex')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// redactURL returns rawURL with its query string — and userinfo, if
|
|
182
|
+
// present — replaced by the literal marker "[redacted]" (D6-bis). Used
|
|
183
|
+
// before persisting a third-party artifact's source URL to disk (the
|
|
184
|
+
// quarantine record and the provenance entry): a pre-signed URL can carry a
|
|
185
|
+
// bearer token in its query string, which would otherwise become a
|
|
186
|
+
// permanent secret in the git history the moment either file is committed.
|
|
187
|
+
// The full, unredacted URL is used only in memory, for the network fetch
|
|
188
|
+
// itself (D7) — never for anything persisted. Mirrors
|
|
189
|
+
// internal/thirdparty/markers.go:RedactURL. Deliberately constructs the
|
|
190
|
+
// result manually (protocol/host/pathname/hash) rather than mutating and
|
|
191
|
+
// re-serializing via the URL object's own .toString(), to stay
|
|
192
|
+
// byte-identical to the Go/Python output shape rather than depend on
|
|
193
|
+
// WHATWG URL serialization nuances.
|
|
194
|
+
function redactURL(rawURL) {
|
|
195
|
+
let parsed
|
|
196
|
+
try {
|
|
197
|
+
parsed = new URL(rawURL)
|
|
198
|
+
} catch {
|
|
199
|
+
return rawURL
|
|
200
|
+
}
|
|
201
|
+
let result = `${parsed.protocol}//${parsed.host}${parsed.pathname}`
|
|
202
|
+
if (parsed.search !== '') result += '?[redacted]'
|
|
203
|
+
result += parsed.hash
|
|
204
|
+
return result
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { checkMarkers, checksum, redactURL, literalMarkers: LITERAL_MARKERS, removeFencedBlocks }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of internal/thirdparty/provenance.go — the D6/D8c provenance
|
|
4
|
+
// record and TOCTOU-closing approval check. See:
|
|
5
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
|
|
6
|
+
// (D6, D8c, D8f).
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs')
|
|
9
|
+
const path = require('node:path')
|
|
10
|
+
|
|
11
|
+
const { atomicWrite } = require('./quarantine')
|
|
12
|
+
const { redactURL } = require('./markers')
|
|
13
|
+
|
|
14
|
+
// PROVENANCE_SCHEMA_VERSION mirrors internal/thirdparty/provenance.go:provenanceSchemaVersion.
|
|
15
|
+
//
|
|
16
|
+
// Bumped 1 -> 2 (ADR-2026-08-15 D2-bis, ML-3B) to add the entry field
|
|
17
|
+
// installed_sha256 (SHA-256 of the NORMALIZED bytes, computed at install
|
|
18
|
+
// time). checksum_sha256 (SHA-256 of the RAW bytes, the D8c approval
|
|
19
|
+
// anchor) is unchanged. No migration path exists or is needed: at the time
|
|
20
|
+
// of the bump no provenance file existed anywhere with schema_version 1.
|
|
21
|
+
const PROVENANCE_SCHEMA_VERSION = 2
|
|
22
|
+
|
|
23
|
+
function emptyProvenance() {
|
|
24
|
+
return { schema_version: PROVENANCE_SCHEMA_VERSION, entries: {} }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// provenancePath returns the on-disk path of the provenance file, rooted at
|
|
28
|
+
// root. Mirrors internal/thirdparty/provenance.go:ProvenancePath.
|
|
29
|
+
function provenancePath(root) {
|
|
30
|
+
return path.join(root, '.trackfw', 'thirdparty-provenance.json')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// loadProvenance reads and validates the provenance file: a missing file is
|
|
34
|
+
// a legitimate "nothing installed from a third party yet" state and returns
|
|
35
|
+
// an empty, schema-valid Provenance; invalid JSON or an unsupported
|
|
36
|
+
// schema_version are fatal and thrown as errors — never silently degraded
|
|
37
|
+
// to empty (D8f). Deliberately asymmetric with readQuarantine (see
|
|
38
|
+
// quarantine.js) — do NOT "fix" this asymmetry, it is intentional. Mirrors
|
|
39
|
+
// internal/thirdparty/provenance.go:LoadProvenance.
|
|
40
|
+
function loadProvenance(root) {
|
|
41
|
+
const filename = provenancePath(root)
|
|
42
|
+
let data
|
|
43
|
+
try {
|
|
44
|
+
data = fs.readFileSync(filename, 'utf8')
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 'ENOENT') return emptyProvenance()
|
|
47
|
+
throw new Error(`read thirdparty provenance: ${err.message}`)
|
|
48
|
+
}
|
|
49
|
+
let prov
|
|
50
|
+
try {
|
|
51
|
+
prov = JSON.parse(data)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`decode thirdparty provenance: ${err.message}`)
|
|
54
|
+
}
|
|
55
|
+
if (prov.schema_version !== PROVENANCE_SCHEMA_VERSION) {
|
|
56
|
+
throw new Error(`unsupported thirdparty provenance schema ${prov.schema_version}`)
|
|
57
|
+
}
|
|
58
|
+
if (!prov.entries || Array.isArray(prov.entries)) prov.entries = {}
|
|
59
|
+
return prov
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// writeProvenance persists prov atomically.
|
|
63
|
+
//
|
|
64
|
+
// Failure here MUST propagate to the caller and abort the installation
|
|
65
|
+
// (D6) — the deliberate opposite of a best-effort/log-and-continue write.
|
|
66
|
+
// Provenance is the only record of who approved a third-party artifact, so
|
|
67
|
+
// losing a write silently would leave an unapproved artifact on disk
|
|
68
|
+
// indistinguishable from an approved one.
|
|
69
|
+
//
|
|
70
|
+
// Every entry's url is passed through redactURL before serialization
|
|
71
|
+
// (D6-bis) — defense-in-depth, mirroring
|
|
72
|
+
// internal/thirdparty/provenance.go:WriteProvenance's doc comment: no
|
|
73
|
+
// command in this codebase writes a provenance entry's url today (D10.2),
|
|
74
|
+
// but this call site guarantees the query string is never persisted here
|
|
75
|
+
// regardless. Idempotent: redacting an already-redacted url is a no-op.
|
|
76
|
+
function writeProvenance(root, prov) {
|
|
77
|
+
const entries = prov.entries || {}
|
|
78
|
+
const redactedEntries = {}
|
|
79
|
+
for (const [dest, entry] of Object.entries(entries)) {
|
|
80
|
+
redactedEntries[dest] = { ...entry, url: redactURL(entry.url) }
|
|
81
|
+
}
|
|
82
|
+
const toWrite = { schema_version: PROVENANCE_SCHEMA_VERSION, entries: redactedEntries }
|
|
83
|
+
const data = `${JSON.stringify(toWrite, null, 2)}\n`
|
|
84
|
+
atomicWrite(provenancePath(root), data, 0o600)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// upsertProvenanceEntry loads the current provenance, sets
|
|
88
|
+
// entries[dest] = entry, and writes it back. The write is fatal-on-failure
|
|
89
|
+
// (see writeProvenance) — callers MUST treat a thrown error as
|
|
90
|
+
// "installation aborted", never log-and-continue. Mirrors
|
|
91
|
+
// internal/thirdparty/provenance.go:UpsertProvenanceEntry.
|
|
92
|
+
function upsertProvenanceEntry(root, dest, entry) {
|
|
93
|
+
const prov = loadProvenance(root)
|
|
94
|
+
prov.entries[dest] = entry
|
|
95
|
+
writeProvenance(root, prov)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// verifyApproval is the D8c TOCTOU close: it only succeeds if dest has a
|
|
99
|
+
// provenance entry whose checksum_sha256 matches checksum exactly and whose
|
|
100
|
+
// approved_by is non-empty. A destination with no entry, an entry for a
|
|
101
|
+
// different checksum, or an entry with an empty approved_by all fail
|
|
102
|
+
// identically: not approved. Mirrors
|
|
103
|
+
// internal/thirdparty/provenance.go:VerifyApproval.
|
|
104
|
+
function verifyApproval(root, checksum, dest) {
|
|
105
|
+
const prov = loadProvenance(root)
|
|
106
|
+
const entry = prov.entries[dest]
|
|
107
|
+
if (!entry) throw new Error(`no provenance entry for destination "${dest}": not approved`)
|
|
108
|
+
if (entry.checksum_sha256 !== checksum) {
|
|
109
|
+
throw new Error(`provenance checksum mismatch for "${dest}": approved ${entry.checksum_sha256}, got ${checksum}`)
|
|
110
|
+
}
|
|
111
|
+
if (!entry.approved_by) throw new Error(`provenance entry for "${dest}" has no approved_by`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { provenancePath, loadProvenance, writeProvenance, upsertProvenanceEntry, verifyApproval, PROVENANCE_SCHEMA_VERSION }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of internal/thirdparty/quarantine.go — the D8a/b on-disk
|
|
4
|
+
// quarantine record for one fetched third-party artifact. See:
|
|
5
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
|
|
6
|
+
// (D8a, D8b, D8f).
|
|
7
|
+
|
|
8
|
+
const crypto = require('node:crypto')
|
|
9
|
+
const fs = require('node:fs')
|
|
10
|
+
const path = require('node:path')
|
|
11
|
+
|
|
12
|
+
const { checksum: sha256hex, redactURL } = require('./markers')
|
|
13
|
+
|
|
14
|
+
// QUARANTINE_SCHEMA_VERSION mirrors internal/thirdparty/quarantine.go:quarantineSchemaVersion.
|
|
15
|
+
const QUARANTINE_SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
// atomicWrite writes data to filename via a temp file in the same
|
|
18
|
+
// directory followed by fs.renameSync, so a reader never observes a
|
|
19
|
+
// partially written file. Mirrors internal/thirdparty/quarantine.go's
|
|
20
|
+
// atomicWrite (shared there by quarantine.go and provenance.go; replicated
|
|
21
|
+
// here and in references.js, same rationale as the Go reference's own
|
|
22
|
+
// per-package duplication of Checksum in markers.go's doc comment).
|
|
23
|
+
function atomicWrite(filename, data, mode) {
|
|
24
|
+
const directory = path.dirname(filename)
|
|
25
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
26
|
+
const tmp = path.join(directory, `.trackfw-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`)
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(tmp, data, { mode })
|
|
29
|
+
fs.chmodSync(tmp, mode)
|
|
30
|
+
fs.renameSync(tmp, filename)
|
|
31
|
+
} finally {
|
|
32
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// quarantinePath returns the on-disk path of the quarantine record for
|
|
37
|
+
// checksum, rooted at root — the project or home directory the caller is
|
|
38
|
+
// operating on. Mirrors internal/thirdparty/quarantine.go:QuarantinePath.
|
|
39
|
+
function quarantinePath(root, checksum) {
|
|
40
|
+
return path.join(root, '.trackfw', 'thirdparty-quarantine', `${checksum}.json`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// newQuarantineEntry builds a quarantine entry from freshly fetched content.
|
|
44
|
+
// matchedMarkers is checkMarkers()'s return value for raw; an empty array
|
|
45
|
+
// yields marker_check.result === "pass". The content is embedded whole,
|
|
46
|
+
// base64-encoded, in content_base64 — never a path to another file (D8b):
|
|
47
|
+
// an indirection through a second file would reopen the TOCTOU window the
|
|
48
|
+
// quarantine record exists to close.
|
|
49
|
+
//
|
|
50
|
+
// rawURL is stored via redactURL, not verbatim (D6-bis) — see
|
|
51
|
+
// internal/thirdparty/quarantine.go:NewQuarantineEntry's doc comment.
|
|
52
|
+
function newQuarantineEntry(rawURL, raw, matchedMarkers, kind, requestedTargets) {
|
|
53
|
+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'utf8')
|
|
54
|
+
return {
|
|
55
|
+
schema_version: QUARANTINE_SCHEMA_VERSION,
|
|
56
|
+
url: redactURL(rawURL),
|
|
57
|
+
checksum_sha256: sha256hex(buf),
|
|
58
|
+
fetched_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
59
|
+
content_base64: buf.toString('base64'),
|
|
60
|
+
marker_check: {
|
|
61
|
+
result: matchedMarkers.length > 0 ? 'fail' : 'pass',
|
|
62
|
+
matched_markers: matchedMarkers,
|
|
63
|
+
},
|
|
64
|
+
kind,
|
|
65
|
+
requested_targets: requestedTargets || [],
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// writeQuarantine persists entry atomically at
|
|
70
|
+
// quarantinePath(root, entry.checksum_sha256). Mirrors
|
|
71
|
+
// internal/thirdparty/quarantine.go:WriteQuarantine.
|
|
72
|
+
function writeQuarantine(root, entry) {
|
|
73
|
+
const toWrite = { ...entry, schema_version: QUARANTINE_SCHEMA_VERSION }
|
|
74
|
+
const data = `${JSON.stringify(toWrite, null, 2)}\n`
|
|
75
|
+
atomicWrite(quarantinePath(root, entry.checksum_sha256), data, 0o600)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// readQuarantine reads and validates the quarantine record for checksum,
|
|
79
|
+
// fail-closed (D8f): a missing file, invalid JSON, or an unsupported
|
|
80
|
+
// schema_version are all thrown as errors, never degraded to an empty
|
|
81
|
+
// value. Deliberately asymmetric with loadProvenance (see provenance.js):
|
|
82
|
+
// here, the caller already holds a checksum obtained from a prior fetch and
|
|
83
|
+
// is asking for that specific record, so its absence is itself the failure
|
|
84
|
+
// being guarded against. Do NOT "fix" this asymmetry — it is intentional,
|
|
85
|
+
// verified against the Go reference by audit. Mirrors
|
|
86
|
+
// internal/thirdparty/quarantine.go:ReadQuarantine.
|
|
87
|
+
function readQuarantine(root, checksum) {
|
|
88
|
+
const filename = quarantinePath(root, checksum)
|
|
89
|
+
let data
|
|
90
|
+
try {
|
|
91
|
+
data = fs.readFileSync(filename, 'utf8')
|
|
92
|
+
} catch (err) {
|
|
93
|
+
throw new Error(`read quarantine entry "${checksum}": ${err.message}`)
|
|
94
|
+
}
|
|
95
|
+
let entry
|
|
96
|
+
try {
|
|
97
|
+
entry = JSON.parse(data)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new Error(`decode quarantine entry "${checksum}": ${err.message}`)
|
|
100
|
+
}
|
|
101
|
+
if (entry.schema_version !== QUARANTINE_SCHEMA_VERSION) {
|
|
102
|
+
throw new Error(`unsupported quarantine schema ${entry.schema_version} for "${checksum}"`)
|
|
103
|
+
}
|
|
104
|
+
return entry
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// decodeContent decodes entry.content_base64 back to the original raw
|
|
108
|
+
// bytes (a Buffer). Mirrors internal/thirdparty/quarantine.go:QuarantineEntry.DecodeContent.
|
|
109
|
+
function decodeContent(entry) {
|
|
110
|
+
return Buffer.from(entry.content_base64, 'base64')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { quarantinePath, newQuarantineEntry, writeQuarantine, readQuarantine, decodeContent, atomicWrite, QUARANTINE_SCHEMA_VERSION }
|