diff --git a/.github/vendor/labpics-code-admission/.github/actions/code-admission/action.yml b/.github/vendor/labpics-code-admission/.github/actions/code-admission/action.yml new file mode 100644 index 00000000..71f83e3d --- /dev/null +++ b/.github/vendor/labpics-code-admission/.github/actions/code-admission/action.yml @@ -0,0 +1,72 @@ +name: Qualified code admission +description: Проверка immutable Git-снимков без исполнения кода продукта и без наследования его scanner policy. +inputs: + repo: + description: Относительный путь единственного product checkout + required: true + base: + description: Точный commit базового состояния + required: true + candidate: + description: Точный commit интеграционного кандидата + required: true +runs: + using: composite + steps: + - name: Select qualified Node runtime + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - name: Qualify and inspect immutable source snapshots + shell: bash + env: + SUBJECT: ${{ inputs.repo }} + BASE: ${{ inputs.base }} + CANDIDATE: ${{ inputs.candidate }} + run: | + set -euo pipefail + [[ "$SUBJECT" =~ ^\.code-subject\.[A-Za-z0-9]{8}$ ]] || exit 2 + [[ "$BASE" =~ ^[a-f0-9]{40}$ && "$CANDIDATE" =~ ^[a-f0-9]{40}$ ]] || exit 2 + root="$GITHUB_ACTION_PATH/../../.." + product="$GITHUB_WORKSPACE/$SUBJECT" + work="$(mktemp -d "$RUNNER_TEMP/code-admission.XXXXXXXX")" + cleanup() { rm -rf -- "$work"; } + trap cleanup EXIT + export TMPDIR="$work" + bash "$root/plans/tools/code-admission/install-tools.sh" "$work/tools" + ast_qualification="$work/ast-qualification.json" + node "$root/plans/tools/code-admission/qualify-ast-grep.mjs" --ast-grep "$work/tools/ast-grep" --rules "$root/plans/tools/code-admission/rules.json" > "$ast_qualification" + cat "$ast_qualification" + ast_digest="$(node -e 'const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if (r.schema !== "labpics.ast-grep/native-qualification/v1" || !/^[a-f0-9]{64}$/.test(r.binaryDigest)) process.exit(2); process.stdout.write(r.binaryDigest)' "$ast_qualification")" + ast_rules_digest="$(node -e 'const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if (r.schema !== "labpics.ast-grep/native-qualification/v1" || !/^[a-f0-9]{64}$/.test(r.rulesDigest)) process.exit(2); process.stdout.write(r.rulesDigest)' "$ast_qualification")" + qualification="$work/trivy-qualification.json" + node "$root/plans/tools/code-admission/qualify-trivy.mjs" "$work/tools/trivy" > "$qualification" + cat "$qualification" + qualified_digest="$(node -e 'const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if (r.schema !== "labpics.trivy/native-qualification/v1" || !/^[a-f0-9]{64}$/.test(r.binaryDigest)) process.exit(2); process.stdout.write(r.binaryDigest)' "$qualification")" + set +e + node "$root/plans/tools/code-admission.mjs" \ + --base "$product" --base-ref "$BASE" \ + --candidate "$product" --candidate-ref "$CANDIDATE" \ + --trusted "$root" --ast-grep "$work/tools/ast-grep" --ast-grep-digest "$ast_digest" --ast-rules-digest "$ast_rules_digest" \ + --trivy "$work/tools/trivy" --trivy-digest "$qualified_digest" --trivy-cache "$work/cache" --out "$work/evidence" + status=$? + set -e + if [[ "$status" == 0 && ( ! -s "$work/evidence/admission.json" || ! -s "$work/evidence/sbom.cdx.json" ) ]]; then + echo '::error::Scanner returned success without complete evidence' + exit 2 + fi + if [[ -f "$work/evidence/admission.json" ]]; then + echo '::group::Qualified admission evidence' + cat "$work/evidence/admission.json" + echo '::endgroup::' + echo '::group::CycloneDX SBOM for the same source subject' + cat "$work/evidence/sbom.cdx.json" + echo '::endgroup::' + sha256sum "$work/evidence/admission.json" "$work/evidence/sbom.cdx.json" + { + echo '### Code admission' + printf 'Candidate: `%s`; baseline: `%s`; exit: `%s`.\n' "$CANDIDATE" "$BASE" "$status" + echo 'HIGH/CRITICAL и найденные секреты блокируют даже при наличии в baseline. Сбой или неполное evidence не означают PASS.' + } >> "$GITHUB_STEP_SUMMARY" + fi + exit "$status" diff --git a/.github/vendor/labpics-code-admission/architecture/git.mjs b/.github/vendor/labpics-code-admission/architecture/git.mjs new file mode 100644 index 00000000..d36e639e --- /dev/null +++ b/.github/vendor/labpics-code-admission/architecture/git.mjs @@ -0,0 +1,195 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { classifyPath, graphEligible, isOid, repoPath } from './model.mjs'; +const MAX_BUFFER = 96 * 1024 * 1024; +const decoder = new TextDecoder('utf-8', { fatal: true }); +const cleanToken = v => v?.replace(/^\n+/, ''); +export class GitReader { + constructor(directory) { + this.directory = path.resolve(directory); + // Isolate Git configuration explicitly instead of poisoning HOME. HOME is + // part of executable discovery for legitimate wrapper installations and + // changing it can make a working Git binary disappear before analysis. + this.environment = { PATH: process.env.PATH, SYSTEMROOT: process.env.SYSTEMROOT, HOME: process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME, + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', GIT_OPTIONAL_LOCKS: '0', GIT_NO_LAZY_FETCH: '1', GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' }; + } + bytes(args, input) { + try { + return execFileSync('git', ['--no-pager', '--no-replace-objects', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'diff.external=', '-c', 'log.showSignature=false', '-c', 'color.ui=false', '-C', this.directory, ...args], { env: this.environment, input, maxBuffer: MAX_BUFFER, timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] }); + } + catch (e) { + throw new Error(`Git ${args[0]} failed (status=${e.status ?? 'unknown'}, code=${e.code ?? 'none'})`); + } + } + text(args) { return decoder.decode(this.bytes(args)).trim(); } + oid(ref) { + if (typeof ref !== 'string' || !ref || ref.length > 4096 || ref.includes('\0')) + throw new TypeError('Invalid Git ref'); + const sha = this.text(['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]); + if (!isOid(sha)) + throw new Error('Expected immutable commit'); + return sha; + } + mergeBase(base, head) { + if (!isOid(base) || !isOid(head)) + throw new TypeError('Expected commit identities'); + const refs = this.text(['merge-base', '--all', base, head]).split('\n'); + if (refs.length !== 1 || !isOid(refs[0])) + throw new Error('Comparison needs one unambiguous merge base'); + return refs[0]; + } + changedPaths(base, head) { + if (!isOid(base) || !isOid(head)) + throw new TypeError('Expected commit identities'); + return nulFields(this.bytes(['diff', '--no-ext-diff', '--no-textconv', '--name-only', '--no-renames', '-z', base, head, '--'])).map(repoPath); + } + tree(commit) { + if (!isOid(commit)) + throw new TypeError('Expected immutable commit'); + return nulFields(this.bytes(['ls-tree', '-rz', '-l', '--full-tree', commit])).map(f => { + const tab = f.indexOf('\t'); + if (tab < 0) + throw new Error('Malformed tree entry'); + const [mode, type, oid, size] = f.slice(0, tab).trim().split(/\s+/); + const p = repoPath(f.slice(tab + 1)); + if (!/^[0-7]{6}$/.test(mode) || !isOid(oid)) + throw new Error('Invalid tree object'); + const s = size === '-' ? null : Number(size); + if (s !== null && (!Number.isSafeInteger(s) || s < 0)) + throw new Error('Invalid tree size'); + return { mode, type, oid, size: s, path: p }; + }); + } + blobs(entries) { + if (!entries.length) + return new Map(); + let budget = 0; + for (const e of entries) { + if (e.type !== 'blob' || !isOid(e.oid) || !Number.isSafeInteger(e.size) || e.size < 0) + throw new TypeError('Invalid blob request'); + budget += e.size + 128; + } + if (budget > MAX_BUFFER - 1024) + throw new Error('Snapshot exceeds 96 MiB resource budget'); + const bytes = this.bytes(['cat-file', '--batch'], entries.map(e => e.oid).join('\n') + '\n'); + let offset = 0; + const result = new Map(); + for (const e of entries) { + const end = bytes.indexOf(10, offset); + if (end < 0) + throw new Error('Truncated blob header'); + const [oid, type, size] = bytes.subarray(offset, end).toString('ascii').split(' '); + if (oid !== e.oid || type !== 'blob' || Number(size) !== e.size) + throw new Error('Blob identity mismatch'); + offset = end + 1; + const content = bytes.subarray(offset, offset + e.size); + offset += e.size; + if (content.length !== e.size || bytes[offset++] !== 10) + throw new Error('Truncated blob'); + result.set(e.path, content); + } + if (offset !== bytes.length) + throw new Error('Unconsumed blob output'); + return result; + } + snapshot(commit, config) { + const entries = this.tree(commit), omitted = []; + const selected = entries.filter(e => { + let reason = null; + if (!['100644', '100755'].includes(e.mode) || e.type !== 'blob') + reason = 'symlink-or-submodule-not-followed'; + else if (classifyPath(e.path, config) === 'excluded-directory') + reason = 'excluded-directory'; + else if (e.size > 1024 * 1024) + reason = 'file-over-1-MiB'; + else if (!graphEligible(e.path, config) && classifyPath(e.path, config) !== 'configuration') + reason = classifyPath(e.path, config); + if (reason) + omitted.push({ path: e.path, reason }); + return reason === null; + }); + if (selected.length > 30000) + throw new Error('Snapshot exceeds 30000-file budget'); + const files = new Map(); + for (const [p, b] of this.blobs(selected)) { + try { + if (b.includes(0)) + throw new Error('binary'); + files.set(p, decoder.decode(b)); + } + catch { + omitted.push({ path: p, reason: 'binary-or-non-UTF8' }); + } + } + return { commit, tree: this.text(['rev-parse', `${commit}^{tree}`]), entries, files, omitted }; + } + readFile(commit, p, maxBytes = 256 * 1024) { + repoPath(p); + const e = this.tree(commit).find(e => e.path === p); + if (!e) + return null; + if (e.type !== 'blob' || !['100644', '100755'].includes(e.mode) || e.size > maxBytes) + throw new Error('Refused non-regular or oversized configuration'); + return decoder.decode(this.blobs([e]).get(p)); + } + history(commit, settings) { + if (!isOid(commit)) + throw new TypeError('Expected immutable commit'); + const until = Number(this.text(['show', '-s', '--format=%ct', commit])); + if (!Number.isSafeInteger(until)) + throw new Error('Invalid timestamp'); + const since = until - settings.windowDays * 86400; + // Filter timestamps after bounded traversal: --since can stop at a skewed date. + const raw = this.bytes(['log', '--first-parent', '--diff-merges=first-parent', '--no-ext-diff', '--no-textconv', '--format=%H%x00%P%x00%ct%x00', '--name-status', '-z', '--find-renames=100%', `--max-count=${settings.maxCommits + 1}`, commit, '--']); + const observed = parseNameStatus(raw), truncated = observed.length > settings.maxCommits, shallow = this.text(['rev-parse', '--is-shallow-repository']) === 'true'; + return { events: observed.slice(0, settings.maxCommits), complete: !truncated && !shallow, truncated, shallow, since, until, subjectCommit: commit, gitVersion: this.text(['--version']), unit: 'first-parent commit; merge contributes its integration diff, not its child commits', renamePolicy: 'exact-content renames only; edited renames may start a new lineage' }; + } +} +export function nulFields(bytes) { + if (!bytes.length) + return []; + if (bytes.at(-1) !== 0) + throw new Error('Truncated NUL-delimited Git output'); + return decoder.decode(bytes.subarray(0, -1)).split('\0'); +} +function looksHeader(f, i) { return isOid(cleanToken(f[i])) && typeof f[i + 1] === 'string' && f[i + 1].split(' ').filter(Boolean).every(isOid) && /^\d+$/.test(f[i + 2] ?? ''); } +export function parseNameStatus(bytes) { + const f = nulFields(bytes), events = [], seen = new Set(); + let i = 0; + while (i < f.length) { + if (!looksHeader(f, i)) + throw new Error('Malformed Git history header'); + const sha = cleanToken(f[i++]), parents = f[i++].split(' ').filter(Boolean), time = Number(f[i++]); + if (!Number.isSafeInteger(time) || seen.has(sha)) + throw new Error('Invalid or duplicate commit'); + seen.add(sha); + const changes = [], paths = new Set(); + while (i < f.length && f[i] === '') + i++; + while (i < f.length && !looksHeader(f, i)) { + if (f[i] === '') { + i++; + continue; + } + const m = cleanToken(f[i++])?.match(/^([ACDMRTUXB])(\d{0,3})$/); + if (!m) + throw new Error('Malformed name-status record'); + const status = m[1]; + let oldPath, p; + if (status === 'R' || status === 'C') { + oldPath = repoPath(f[i++]); + p = repoPath(f[i++]); + } + else { + p = repoPath(f[i++]); + oldPath = p; + } + if (paths.has(p)) + throw new Error('Duplicate changed path'); + paths.add(p); + changes.push({ status, oldPath, path: p, ...(['R', 'C'].includes(status) ? { similarity: Number(m[2]) } : {}) }); + } + events.push({ sha, parents, time, changes }); + } + return events; +} diff --git a/.github/vendor/labpics-code-admission/architecture/model.mjs b/.github/vendor/labpics-code-admission/architecture/model.mjs new file mode 100644 index 00000000..ce4ca8e8 --- /dev/null +++ b/.github/vendor/labpics-code-admission/architecture/model.mjs @@ -0,0 +1,220 @@ +import { createHash } from 'node:crypto'; +import path from 'node:path'; +export const TOOL_VERSION = '0.3.0'; +export const REPORT_SCHEMA = 'labpics.architecture/report/v2'; +export const GRAPH_SCHEMA = 'labpics.architecture/graph/v2'; +export const BASELINE_SCHEMA = 'labpics.architecture/baseline/v1'; +export const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0; +export const unique = values => [...new Set(values)].sort(compareText); +export function stableJson(value) { + if (Array.isArray(value)) + return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') + return `{${Object.keys(value).sort(compareText).map(k => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`; + if (value === undefined || typeof value === 'bigint' || typeof value === 'function' || (typeof value === 'number' && !Number.isFinite(value))) + throw new TypeError('Value is not canonical JSON'); + return JSON.stringify(value); +} +export const digest = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : stableJson(value)).digest('hex'); +export const isOid = value => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value); +export function object(value, allowed, where) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError(`${where}: expected object`); + for (const key of Object.keys(value)) + if (!allowed.includes(key)) + throw new TypeError(`${where}: unknown field`); +} +export function text(value, where, max = 4096) { + if (typeof value !== 'string' || !value || value.length > max || value.includes('\0')) + throw new TypeError(`${where}: invalid text`); + return value; +} +export function number(value, min, max, where, integer = true) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) + throw new TypeError(`${where}: out of range`); + return value; +} +export function repoPath(value) { + text(value, 'repository path'); + if (value.startsWith('/') || /^[a-z]:/i.test(value) || value.includes('\\') || value.split('/').some(s => !s || s === '.' || s === '..')) + throw new TypeError('Non-canonical repository path'); + return value; +} +function prefixes(values, where) { + if (!Array.isArray(values) || !values.length || values.length > 1000) + throw new TypeError(`${where}: expected nonempty prefix array`); + return unique(values.map(v => v === '' ? v : repoPath(v.replace(/\/$/, '')))); +} +export const within = (candidate, prefix) => prefix === '' || candidate === prefix || candidate.startsWith(`${prefix}/`); +export function configFrom(value = {}) { + object(value, ['schemaVersion', 'history', 'excludeDirectories', 'components', 'rules'], 'configuration'); + if (value.schemaVersion !== undefined && value.schemaVersion !== 1) + throw new TypeError('Unsupported configuration schemaVersion'); + const fields = ['maxCommits', 'windowDays', 'maxChangesetFiles', 'minShared', 'minJaccard', 'minConditional', 'minLift', 'maxPairs']; + if (value.history !== undefined) + object(value.history, fields, 'history'); + const history = { maxCommits: 2500, windowDays: 365, maxChangesetFiles: 80, minShared: 4, minJaccard: 0.35, minConditional: 0.8, minLift: 1.5, maxPairs: 300000, ...value.history }; + for (const [key, min, max] of [['maxCommits', 1, 100000], ['windowDays', 1, 36500], ['maxChangesetFiles', 2, 1000], ['minShared', 1, 100000], ['maxPairs', 1, 5000000]]) + number(history[key], min, max, `history.${key}`); + for (const key of ['minJaccard', 'minConditional']) + number(history[key], 0, 1, `history.${key}`, false); + number(history.minLift, 0, 100000, 'history.minLift', false); + const excluded = value.excludeDirectories ?? ['.git', '.next', '.turbo', 'coverage', 'dist', 'node_modules', 'target', 'vendor']; + if (!Array.isArray(excluded) || excluded.length > 1000 || excluded.some(s => typeof s !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(s))) + throw new TypeError('Invalid excludeDirectories'); + if (!Array.isArray(value.components ?? []) || (value.components?.length ?? 0) > 10000) + throw new TypeError('Invalid components'); + const ids = new Set(); + const components = (value.components ?? []).map(c => { + object(c, ['id', 'prefixes'], 'component'); + text(c.id, 'component.id', 512); + if (ids.has(c.id) || (c.id.startsWith('external:') || c.id.startsWith('directory:'))) + throw new TypeError('Duplicate or reserved component identity'); + ids.add(c.id); + return { id: c.id, prefixes: prefixes(c.prefixes, 'component.prefixes') }; + }).sort((a, b) => compareText(a.id, b.id)); + // Index actual path ancestors instead of comparing every pair of prefixes. + const prefixOwners = new Map(); + for (const component of components) + for (const prefix of component.prefixes) { + if (prefixOwners.has(prefix)) + throw new TypeError('Overlapping components'); + prefixOwners.set(prefix, component.id); + } + for (const [prefix, id] of prefixOwners) { + const segments = prefix.split('/'); + for (let depth = 0; depth < segments.length; depth++) { + const owner = prefixOwners.get(segments.slice(0, depth).join('/')); + if (owner !== undefined && owner !== id) + throw new TypeError('Overlapping components'); + } + } + if (!Array.isArray(value.rules ?? []) || (value.rules?.length ?? 0) > 2000) + throw new TypeError('Invalid rules'); + ids.clear(); + const rules = (value.rules ?? []).map(r => { + object(r, ['id', 'kind', 'from', 'to', 'scope', 'rationale'], 'rule'); + text(r.id, 'rule.id', 512); + text(r.rationale, 'rule.rationale'); + if (ids.has(r.id)) + throw new TypeError('Duplicate rule'); + ids.add(r.id); + if (r.kind === 'forbidden-dependency' || r.kind === 'forbidden-reachability') { + if (r.scope !== undefined) + throw new TypeError('Forbidden dependency does not accept scope'); + return { id: r.id, kind: r.kind, from: prefixes(r.from, 'rule.from'), to: prefixes(r.to, 'rule.to'), rationale: r.rationale }; + } + if (r.kind === 'acyclic') { + if (r.from !== undefined || r.to !== undefined) + throw new TypeError('Acyclic accepts scope only'); + return { id: r.id, kind: r.kind, scope: prefixes(r.scope, 'rule.scope'), rationale: r.rationale }; + } + throw new TypeError('Unsupported rule kind'); + }).sort((a, b) => compareText(a.id, b.id)); + return { schemaVersion: 1, history, excludeDirectories: unique(excluded), components, rules }; +} +export function classifyPath(candidate, config) { + const segments = candidate.split('/'), name = segments.at(-1); + if (segments.some(s => config.excludeDirectories.includes(s))) + return 'excluded-directory'; + // A concurrency module named distributed-lock.ts is source, not a package + // lockfile. Match actual artifact names/extensions, never an embedded word. + if (['go.sum', 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'pnpm-lock.yml', 'bun.lockb'].includes(name) || name.endsWith('.lock')) + return 'lockfile'; + if (/\.(?:md|mdx|rst|txt|svg|png|jpe?g|gif|webp|pdf|woff2?|ttf)$/i.test(candidate)) + return 'documentation-or-asset'; + if (/(?:^|\/)(?:tests?|__tests__)(?:\/|$)|(?:\.test|\.spec|_test)\.[^.]+$|(?:^|\/)(?:test_[^/]*|conftest)\.py$/i.test(candidate)) + return 'test'; + if (/\.(?:[cm]?[jt]sx?|go|rs|py|java|kt|kts|cs|c|cc|cpp|cxx|h|hpp|rb|php|swift|scala|vue|svelte|ex|exs)$/i.test(candidate)) + return 'source'; + if (/\.(?:json|jsonc|toml|ya?ml|sh|bash|ps1|tf|hcl|proto|graphql|sql)$/i.test(candidate) || /^(?:Dockerfile|Makefile|go\.mod|\.gitignore|\.gitattributes)$/.test(name)) + return 'configuration'; + return 'other'; +} +export const historyEligible = (p, c) => ['source', 'configuration'].includes(classifyPath(p, c)); +export const graphEligible = (p, c) => classifyPath(p, c) === 'source'; +export function componentFor(candidate, config) { + const declared = config.components.find(c => c.prefixes.some(p => within(candidate, p))); + const dir = path.posix.dirname(candidate); + return declared ? { id: declared.id, basis: 'declared' } : { id: `directory:${dir === '.' ? '(root)' : dir}`, basis: 'directory-fallback' }; +} +export function quantile(values, q) { + if (!values.length) + return 0; + const sorted = [...values].sort((a, b) => a - b), i = (sorted.length - 1) * q, lo = Math.floor(i), hi = Math.ceil(i); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} +export function graphFrom(value, commit, treePaths = null) { + object(value, ['schema', 'commit', 'producer', 'nodes', 'edges', 'limitations', 'coverage'], 'graph'); + if (value.schema !== GRAPH_SCHEMA || value.commit !== commit) + throw new TypeError('Graph schema or subject commit mismatch'); + object(value.producer, ['name', 'version', 'digest'], 'graph.producer'); + text(value.producer.name, 'producer.name', 512); + text(value.producer.version, 'producer.version', 512); + if (value.producer.digest !== undefined && !/^[a-f0-9]{64}$/.test(value.producer.digest)) + throw new TypeError('Invalid producer digest'); + if (!Array.isArray(value.nodes) || value.nodes.length > 100000 || !Array.isArray(value.edges) || value.edges.length > 1000000) + throw new TypeError('Graph resource budget exceeded'); + const ids = new Set(), owners = new Map(); + const nodes = value.nodes.map(n => { + object(n, ['id', 'external', 'paths', 'basis'], 'node'); + text(n.id, 'node.id'); + text(n.basis, 'node.basis', 512); + if (ids.has(n.id) || typeof n.external !== 'boolean' || !Array.isArray(n.paths) || (n.external ? n.paths.length : !n.paths.length)) + throw new TypeError('Invalid graph node'); + ids.add(n.id); + const paths = unique(n.paths.map(repoPath)); + for (const p of paths) { + if (treePaths && !treePaths.has(p)) + throw new TypeError('Graph path absent from subject'); + if (owners.has(p)) + throw new TypeError('Ambiguous graph path ownership'); + owners.set(p, n.id); + } + return { ...n, paths }; + }).sort((a, b) => compareText(a.id, b.id)); + const edges = value.edges.map(e => { + object(e, ['from', 'to', 'kind', 'path', 'line'], 'edge'); + repoPath(e.path); + if (!ids.has(e.from) || !ids.has(e.to) || owners.get(e.path) !== e.from) + throw new TypeError('Invalid graph edge endpoint or witness'); + if (!['import', 'type-import', 're-export', 'dynamic-import', 'require', 'declared-dependency'].includes(e.kind)) + throw new TypeError('Unsupported edge kind'); + number(e.line, 1, 1000000000, 'edge.line'); + return { ...e }; + }); + const dedup = new Map(edges.map(e => [stableJson(e), e])); + if (!Array.isArray(value.limitations) || value.limitations.length > 100000) + throw new TypeError('Invalid graph limitations'); + const limitations = value.limitations.map(l => { + object(l, ['path', 'reason'], 'limitation'); + if (l.path !== null) + repoPath(l.path); + text(l.reason, 'limitation.reason'); + return { ...l }; + }); + object(value.coverage, ['sourceFiles', 'parsedFiles', 'ratio', 'omittedSnapshotFiles', 'complete'], 'coverage'); + const c = value.coverage; + number(c.sourceFiles, 0, 1000000, 'sourceFiles'); + number(c.parsedFiles, 0, c.sourceFiles, 'parsedFiles'); + number(c.omittedSnapshotFiles, 0, 1000000, 'omittedSnapshotFiles'); + const ratio = c.sourceFiles ? c.parsedFiles / c.sourceFiles : 0; + if (c.ratio !== ratio || typeof c.complete !== 'boolean' || (c.complete && (c.parsedFiles !== c.sourceFiles || !c.sourceFiles || limitations.length))) + throw new TypeError('Inconsistent graph coverage'); + return { schema: GRAPH_SCHEMA, commit, producer: { ...value.producer }, nodes, edges: [...dedup].sort(([a], [b]) => compareText(a, b)).map(([, e]) => e), limitations, coverage: { ...c } }; +} +export function baselineFrom(value) { + object(value, ['schema', 'accepted'], 'baseline'); + if (value.schema !== BASELINE_SCHEMA || !Array.isArray(value.accepted) || value.accepted.length > 100000) + throw new TypeError('Unsupported baseline schema'); + const seen = new Set(); + const accepted = value.accepted.map(e => { + object(e, ['fingerprint', 'ruleId'], 'baseline entry'); + text(e.ruleId, 'ruleId', 512); + if (!/^[a-f0-9]{64}$/.test(e.fingerprint) || seen.has(e.fingerprint)) + throw new TypeError('Invalid baseline fingerprint'); + seen.add(e.fingerprint); + return { ...e }; + }).sort((a, b) => compareText(a.fingerprint, b.fingerprint)); + return { schema: BASELINE_SCHEMA, accepted }; +} diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission.mjs b/.github/vendor/labpics-code-admission/plans/tools/code-admission.mjs new file mode 100644 index 00000000..1539978d --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtempSync, + readFileSync, + readdirSync, + statSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, basename, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { hasInlineScannerControl, scannerConfigurationPath } from "./code-admission/controls.mjs"; +import { materializeSnapshot } from "./code-admission/snapshot.mjs"; +import { prepareQualifiedExecutable } from "./code-admission/identity.mjs"; +import { blockingSecurityFindings, createTrivySession } from "./code-admission/trivy.mjs"; + +const MAX_OUTPUT = 64 * 1024 * 1024; +const MAX_TEXT_FILE = 64 * 1024 * 1024; +const SKIP_WALK_DIRS = new Set([".git", "node_modules", "target", ".venv", "vendor", "dist", "build", ".next"]); + +const sha256 = (value) => createHash("sha256").update(String(value)).digest("hex"); +const slash = (value) => String(value).split(sep).join("/"); +const compactWhitespace = (value) => String(value ?? "").replace(/\s+/g, " ").trim(); + +const canonicalRelative = (root, raw) => { + if (typeof raw !== "string" || !raw || /[\x00-\x1f\x7f]/.test(raw)) throw new TypeError("Invalid analyzer source path"); + const candidate = isAbsolute(raw) ? resolve(raw) : resolve(root, raw); + const result = relative(resolve(root), candidate); + if (!result || isAbsolute(result) || result === ".." || result.startsWith(`..${sep}`)) throw new TypeError("Analyzer source path is outside snapshot"); + return slash(result); +}; + +export const multisetDifference = (baseFindings, candidateFindings) => { + const remaining = new Map(); + for (const finding of baseFindings) { + remaining.set(finding.fingerprint, (remaining.get(finding.fingerprint) ?? 0) + 1); + } + const added = []; + for (const finding of candidateFindings) { + const count = remaining.get(finding.fingerprint) ?? 0; + if (count > 0) remaining.set(finding.fingerprint, count - 1); + else added.push(finding); + } + return added; +}; + +export const normalizeAstMatches = (matches, root) => { + if (!Array.isArray(matches)) throw new TypeError("ast-grep JSON must be an array"); + return matches.map((match) => { + const path = canonicalRelative(root, match.file); + const rule = String(match.ruleId ?? "ast-grep"); + const text = compactWhitespace(match.text); + const start = match?.range?.start?.line; + if (typeof match.text !== "string" || !Number.isSafeInteger(start) || start < 0) throw new TypeError("Invalid analyzer source witness"); + const line = start + 1; + return { + fingerprint: `ast:${rule}:${path}:${sha256(text)}`, + source: "ast-grep", + rule, + path, + line, + message: String(match.message ?? `Structural rule ${rule} matched`), + }; + }); +}; + +export { normalizeTrivyReport } from "./code-admission/trivy.mjs"; + +const fallbackWalk = (root) => { + const files = []; + const visit = (dir) => { + for (const name of readdirSync(dir).sort()) { + if (name === ".git") continue; + const path = join(dir, name); + const stat = statSync(path, { throwIfNoEntry: false }); + if (!stat) continue; + if (stat.isDirectory()) visit(path); + else if (stat.isFile()) files.push(path); + } + }; + visit(root); + return files; +}; + +const trackedFiles = (root) => { + const top = spawnSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + if (top.status !== 0 || resolve(top.stdout.trim()) !== resolve(root)) return fallbackWalk(root); + const git = spawnSync("git", ["-C", root, "ls-files", "-z"], { + encoding: "buffer", + maxBuffer: MAX_OUTPUT, + stdio: ["ignore", "pipe", "ignore"], + }); + if (git.status === 0) { + return git.stdout + .toString("utf8") + .split("\0") + .filter(Boolean) + .map((path) => join(root, path)); + } + return fallbackWalk(root); +}; + +const readSmallText = (path) => { + const stat = statSync(path, { throwIfNoEntry: false }); + if (!stat?.isFile() || stat.size > MAX_TEXT_FILE) return null; + const bytes = readFileSync(path); + if (bytes.includes(0)) return null; + return bytes.toString("utf8"); +}; + +const SUPPRESSION_PATTERNS = Object.freeze([ + { rule: "lab-bypass-ast-grep-ignore", regex: /ast-grep-ignore\s*:/g, message: "ast-grep suppression is owned by trusted policy, not candidate code" }, +]); + +export const collectBypassFindings = (root, configTargets = []) => { + const detectedConfigs = new Set(configTargets); + const findings = []; + for (const absolute of trackedFiles(root)) { + const path = canonicalRelative(root, absolute); + const name = basename(path); + const text = readSmallText(absolute); + if (text === null) continue; + + if (name === ".trivyignore" || name.startsWith(".trivyignore.")) { + findings.push({ + fingerprint: `bypass:trivy-ignore:${path}:${sha256(text)}`, + source: "policy", + rule: "lab-bypass-trivy-ignore", + path, + line: 1, + message: "Trivy suppressions must be changed in trusted policy, not candidate code", + }); + } + + const lines = text.split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + const lineText = lines[index]; + if ((scannerConfigurationPath(path) || detectedConfigs.has(path)) && hasInlineScannerControl(lineText)) { + findings.push({ + fingerprint: `bypass:trivy-inline:${path}:${sha256(compactWhitespace(lineText))}`, + source: "policy", kind: "scanner-control", severity: "UNKNOWN", + rule: "lab-bypass-trivy-inline", path, line: index + 1, + message: "Candidate-owned inline scanner suppression requires external policy review; hidden findings are not a clean scan", + }); + } + for (const pattern of SUPPRESSION_PATTERNS) { + pattern.regex.lastIndex = 0; + if (!pattern.regex.test(lineText)) continue; + findings.push({ + fingerprint: `bypass:${pattern.rule}:${path}:${sha256(compactWhitespace(lineText))}`, + source: "policy", + rule: pattern.rule, + path, + line: index + 1, + message: pattern.message, + }); + } + } + } + return findings; +}; + +const commandJson = (runtime, args, { allowedStatuses = [0], cwd, home = process.env.HOME } = {}) => { + const result = runtime.run(args, { + cwd, + encoding: "utf8", + maxBuffer: MAX_OUTPUT, + stdio: ["ignore", "pipe", "pipe"], + timeout: 120000, + env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1" }, + }); + if (result.error) throw new Error(`${command} failed to start: ${result.error.message}`); + if (!allowedStatuses.includes(result.status)) { + throw new Error(`Structural analyzer exited ${result.status}; diagnosticDigest=${sha256(result.stderr ?? "")}`); + } + try { + if (!result.stdout?.trim()) throw new Error("empty output"); + return JSON.parse(result.stdout); + } catch (error) { + throw new Error("Structural analyzer returned invalid or empty JSON"); + } +}; + +const AST_EXTENSIONS = Object.freeze({ + rust: new Set([".rs"]), + go: new Set([".go"]), + typescript: new Set([".ts", ".tsx", ".mts", ".cts"]), + javascript: new Set([".js", ".jsx", ".mjs", ".cjs"]), +}); + +const extension = (path) => { + const name = basename(path); + const index = name.lastIndexOf("."); + return index === -1 ? "" : name.slice(index).toLowerCase(); +}; + +export const validateStructuralRules = (value) => { + if (!Array.isArray(value) || value.length === 0) throw new Error("trusted structural rule set must be a non-empty array"); + const ids = new Set(); + return value.map((rule, index) => { + if (!rule || typeof rule !== "object") throw new Error(`structural rule ${index} must be an object`); + const id = String(rule.id ?? ""); + const language = String(rule.language ?? "").toLowerCase(); + const pattern = String(rule.pattern ?? ""); + const message = String(rule.message ?? ""); + if (!/^lab-[a-z0-9-]+$/.test(id)) throw new Error(`invalid structural rule id: ${id}`); + if (ids.has(id)) throw new Error(`duplicate structural rule id: ${id}`); + if (!AST_EXTENSIONS[language]) throw new Error(`unsupported structural rule language: ${language}`); + if (!pattern || !message) throw new Error(`structural rule ${id} requires pattern and message`); + ids.add(id); + return Object.freeze({ id, language, pattern, message }); + }); +}; + +const astFindings = ({ root, runtime, rules, policyDirectory }) => { + const config = join(policyDirectory, "ast-grep-policy.yml"); + writeFileSync(config, "ruleDirs: []\n", { mode: 0o600 }); + const files = trackedFiles(root); + const findings = []; + for (const rule of rules) { + const extensions = AST_EXTENSIONS[rule.language]; + const inputs = files.filter((path) => extensions.has(extension(path))); + for (let offset = 0; offset < inputs.length; offset += 128) { + const chunk = inputs.slice(offset, offset + 128); + if (chunk.length === 0) continue; + const report = commandJson(runtime, [ + "run", "--config", config, "--threads", "2", + ...["hidden", "dot", "exclude", "global", "parent", "vcs"].flatMap(kind => ["--no-ignore", kind]), + "--pattern", rule.pattern, + "--lang", rule.language, + "--json=compact", + "--color", "never", + ...chunk, + ], { allowedStatuses: [0, 1], cwd: policyDirectory, home: policyDirectory }); + findings.push(...normalizeAstMatches(report.map((match) => ({ + ...match, + ruleId: rule.id, + message: rule.message, + })), root)); + } + } + return findings; +}; + + +export const formatFinding = (finding) => + `${finding.rule} ${finding.path}:${finding.line} ${finding.message}`; + +export const parseArgs = (argv) => { + const allowed = new Set(["base", "base-ref", "candidate", "candidate-ref", "trusted", "ast-grep", "ast-grep-digest", "ast-rules-digest", "trivy", "trivy-digest", "trivy-cache", "out"]); + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) throw new Error(`invalid argument near ${key ?? ""}`); + if (!allowed.has(key.slice(2)) || values.has(key.slice(2))) throw new Error("unknown or duplicate admission argument"); + values.set(key.slice(2), value); + } + return Object.fromEntries(values); +}; + +const inspectSnapshots = ({ base, candidate, trusted, astGrep, astDigest, astRulesDigest, trivy, trivyDigest, cacheDir }) => { + const rulesPath = join(trusted, "plans/tools/code-admission/rules.json"); + const rulesRaw = readFileSync(rulesPath, "utf8"); + if (sha256(rulesRaw) !== astRulesDigest) throw new Error("ast-grep rules do not match their qualified digest"); + const rules = validateStructuralRules(JSON.parse(rulesRaw)); + + const policyDirectory = join(cacheDir, "ast-policy"); + mkdirSync(policyDirectory, { recursive: true, mode: 0o700 }); + const astRuntime = prepareQualifiedExecutable(astGrep, astDigest, cacheDir, "ast-grep binary"); + let baseAst, candidateAst; + try { + baseAst = astFindings({ root: base, runtime: astRuntime, rules, policyDirectory }); + candidateAst = astFindings({ root: candidate, runtime: astRuntime, rules, policyDirectory }); + } finally { astRuntime.close(); } + const session = createTrivySession(trivy, cacheDir, trivyDigest); + let candidateScan; + try { candidateScan = session.scan(candidate); } + finally { session.close(); } + // Security has no grandfathering. A second baseline scan cannot change the + // decision and needlessly doubles source work and database exposure. + const baseBypass = collectBypassFindings(base); + const candidateBypass = collectBypassFindings(candidate, candidateScan.evidence.configTargets); + const candidateTrivy = candidateScan.findings; + const sourceControls = candidateBypass.filter(finding => finding.kind === "scanner-control"); + const blocking = [...blockingSecurityFindings(candidateTrivy), ...sourceControls]; + const baseFindings = [...baseAst, ...baseBypass]; + const candidateFindings = [...candidateAst, ...candidateBypass]; + const added = multisetDifference(baseFindings, candidateFindings) + .sort((left, right) => formatFinding(left).localeCompare(formatFinding(right))); + + return { + valid: added.length === 0 && blocking.length === 0, + structural: { tool: { name: "ast-grep", binaryDigest: astDigest }, rulesDigest: astRulesDigest }, + security: { mode: "strict-candidate", findings: candidateTrivy, sourceControls, blocking, evidence: candidateScan.evidence, baselineEvidence: null, baselineReason: "not-scanned-no-security-grandfathering" }, + sbom: candidateScan.sbom, + baseCount: baseFindings.length, + candidateCount: candidateFindings.length, + added, + counts: { + ast: { base: baseAst.length, candidate: candidateAst.length }, + bypass: { base: baseBypass.length, candidate: candidateBypass.length }, + trivy: { base: null, candidate: candidateTrivy.length }, + }, + }; +}; + +export const runAdmission = ({ base, candidate, trusted, astGrep, astDigest, astRulesDigest, trivy, trivyDigest, cacheDir, baseRef = "HEAD", candidateRef = "HEAD" }) => { + if (!/^[a-f0-9]{64}$/.test(astDigest ?? "")) throw new TypeError("A qualified ast-grep binary digest is required"); + if (!/^[a-f0-9]{64}$/.test(astRulesDigest ?? "")) throw new TypeError("A qualified ast-grep rules digest is required"); + if (!/^[a-f0-9]{64}$/.test(trivyDigest ?? "")) throw new TypeError("A qualified Trivy binary digest is required"); + mkdirSync(cacheDir, { recursive: true }); + const work = mkdtempSync(join(cacheDir, "source-snapshots-")); + try { + const basePath = join(work, "base"), candidatePath = join(work, "candidate"); + const subjects = { + base: materializeSnapshot(base, baseRef, basePath), + candidate: materializeSnapshot(candidate, candidateRef, candidatePath), + }; + const result = inspectSnapshots({ base: basePath, candidate: candidatePath, trusted, astGrep, astDigest, astRulesDigest, trivy, trivyDigest, cacheDir: work }); + return { schema: "labpics.code-admission/v2", outcome: result.valid ? "passed" : "rejected", subjects, ...result }; + } finally { rmSync(work, { recursive: true, force: true }); } +}; + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const args = parseArgs(process.argv.slice(2)); + for (const name of ["base", "candidate", "trusted", "ast-grep", "trivy"]) { + if (!args[name]) throw new Error(`missing --${name}`); + } + const cacheDir = args["trivy-cache"] ?? mkdtempSync(join(tmpdir(), "labpics-trivy-")); + const result = runAdmission({ + base: resolve(args.base), + candidate: resolve(args.candidate), + trusted: resolve(args.trusted), + astGrep: resolve(args["ast-grep"]), + astDigest: args["ast-grep-digest"], + astRulesDigest: args["ast-rules-digest"], + trivy: resolve(args.trivy), + trivyDigest: args["trivy-digest"], + cacheDir: resolve(cacheDir), + baseRef: args["base-ref"] ?? "HEAD", + candidateRef: args["candidate-ref"] ?? "HEAD", + }); + if (args.out) { + mkdirSync(resolve(args.out), { recursive: true }); + const { sbom, ...evidence } = result; + writeFileSync(join(resolve(args.out), "admission.json"), JSON.stringify(evidence, null, 2) + "\n"); + writeFileSync(join(resolve(args.out), "sbom.cdx.json"), JSON.stringify(sbom, null, 2) + "\n"); + } + console.log(`code admission: base=${result.baseCount} candidate=${result.candidateCount}`); + console.log(` ast-grep: ${result.counts.ast.base} -> ${result.counts.ast.candidate}`); + console.log(` policy bypass: ${result.counts.bypass.base} -> ${result.counts.bypass.candidate}`); + console.log(` trivy: ${result.counts.trivy.candidate} findings on exact candidate; no baseline exemption`); + if (!result.valid) { + console.error(`code admission rejected: ${result.added.length} new findings and ${result.security.blocking.length} blocking security findings:`); + for (const finding of [...new Map([...result.added, ...result.security.blocking].map(f => [f.fingerprint, f])).values()]) { + console.error(`- ${formatFinding(finding)}`); + const safeMessage = `${finding.rule}: ${finding.message}`.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + console.error(`::error file=${encodeURIComponent(finding.path)},line=${finding.line},title=${encodeURIComponent(finding.rule)}::${safeMessage}`); + } + process.exit(1); + } + console.log("code admission accepted: no new structural debt and no blocking security findings"); + } catch (error) { + console.error(`code admission infrastructure failure: ${error.message}`); + process.exit(2); + } +} diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/controls.mjs b/.github/vendor/labpics-code-admission/plans/tools/code-admission/controls.mjs new file mode 100644 index 00000000..dc4a37eb --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/controls.mjs @@ -0,0 +1,23 @@ +import { basename, extname } from 'node:path'; + +// Pinned Trivy 0.74.0 pkg/iac/ignore/parse.go is the authority for directive +// tokenization. This is admission of scanner controls, not a CVE detector. +const SPACE = '[\\u0009-\\u000d\\u0020\\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]'; +const trimSpace = value => value.replace(new RegExp(`^${SPACE}+|${SPACE}+$`, 'g'), ''); +const CONFIG_EXTENSIONS = new Set(['.tf', '.tfvars', '.hcl', '.json', '.jsonc', '.yaml', '.yml', '.template', '.tpl', '.tftpl']); +export function scannerConfigurationPath(path) { + const name = basename(path).toLowerCase(); + return /^(dockerfile|containerfile)([.-]|$)/.test(name) || /\.(dockerfile|containerfile)$/.test(name) || CONFIG_EXTENSIONS.has(extname(name)); +} +export function hasInlineScannerControl(line) { + for (let token of trimSpace(line).split(' ')) { + token = trimSpace(token).replace(/^[#/*]+/, ''); + const prefix = token.startsWith('trivy:') ? 6 : token.startsWith('tfsec:') ? 6 : 0; + if (!prefix) continue; + const sections = token.slice(prefix).split(':'); + for (let i = 0; i + 1 < sections.length; i += 2) { + if (sections[i] === 'ignore' && sections[i + 1].split('[')[0]) return true; + } + } + return false; +} diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/identity.mjs b/.github/vendor/labpics-code-admission/plans/tools/code-admission/identity.mjs new file mode 100644 index 00000000..33dea8f9 --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/identity.mjs @@ -0,0 +1,87 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, closeSync, constants, copyFileSync, fstatSync, lstatSync, + mkdirSync, mkdtempSync, openSync, readSync, rmSync, unlinkSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; + +const SHA256 = /^[a-f0-9]{64}$/; +const BUFFER_BYTES = 256 * 1024; + +function descriptorDigest(fd) { + const hash = createHash('sha256'), buffer = Buffer.alloc(BUFFER_BYTES); + let position = 0, count; + while ((count = readSync(fd, buffer, 0, buffer.length, position)) > 0) { + hash.update(buffer.subarray(0, count)); + position += count; + } + return hash.digest('hex'); +} + +export function fileDigest(file) { + const fd = openSync(file, 'r'); + try { return descriptorDigest(fd); } + finally { closeSync(fd); } +} + +function executableIdentity(fd) { + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size <= 0) throw new Error('Qualified executable is not a regular non-empty file'); + if ((stat.mode & 0o222) !== 0) throw new Error('Qualified executable must be read-only'); + return { dev: stat.dev, ino: stat.ino, size: stat.size, nlink: stat.nlink }; +} + +export function prepareQualifiedExecutable(file, expectedDigest, parent, name) { + if (process.platform !== 'linux') throw new Error('Qualified descriptor execution requires Linux'); + if (!SHA256.test(expectedDigest ?? '')) throw new TypeError('A qualified ' + name + ' digest is required'); + const source = resolve(file), sourceStat = lstatSync(source); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(name + ' source must be a regular file'); + + mkdirSync(parent, { recursive: true, mode: 0o700 }); + const directory = mkdtempSync(join(parent, 'qualified-exec-')); + const staged = join(directory, 'tool'); + let fd = null, closed = false; + try { + copyFileSync(source, staged, constants.COPYFILE_EXCL); + chmodSync(staged, 0o500); + fd = openSync(staged, constants.O_RDONLY); + const before = executableIdentity(fd); + if (descriptorDigest(fd) !== expectedDigest) throw new Error(name + ' does not match its qualified digest'); + unlinkSync(staged); + const sealed = executableIdentity(fd); + if (sealed.nlink !== 0 || sealed.dev !== before.dev || sealed.ino !== before.ino) + throw new Error(name + ' anonymous execution identity is invalid'); + + const verify = () => { + if (closed) throw new Error(name + ' execution identity is closed'); + const current = executableIdentity(fd); + if (current.dev !== sealed.dev || current.ino !== sealed.ino || current.size !== sealed.size || current.nlink !== 0) + throw new Error(name + ' execution identity changed'); + if (descriptorDigest(fd) !== expectedDigest) throw new Error(name + ' anonymous bytes changed'); + }; + const run = (args, options = {}) => { + verify(); + const stdio = options.stdio ?? ['ignore', 'pipe', 'pipe']; + if (!Array.isArray(stdio) || stdio.length !== 3) throw new TypeError(name + ' requires exactly three stdio channels'); + const result = spawnSync('/proc/self/fd/3', args, { ...options, stdio: [...stdio, fd] }); + verify(); + return result; + }; + return { + digest: expectedDigest, + run, + verify, + close() { + if (closed) return; + closed = true; + closeSync(fd); + rmSync(directory, { recursive: true, force: true }); + }, + }; + } catch (error) { + if (fd !== null) try { closeSync(fd); } catch {} + rmSync(directory, { recursive: true, force: true }); + throw error; + } +} diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/install-tools.sh b/.github/vendor/labpics-code-admission/plans/tools/code-admission/install-tools.sh new file mode 100755 index 00000000..76b4a3c0 --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/install-tools.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "usage: install-tools.sh [all|ast-grep]" >&2 + exit 2 +fi +selection="${2:-all}" +case "$selection" in all|ast-grep) ;; *) echo "unsupported tool selection" >&2; exit 2 ;; esac +if [ "$(uname -s)" != "Linux" ] || [ "$(uname -m)" != "x86_64" ]; then + echo "code-admission tool bundle currently requires Linux x86_64" >&2 + exit 2 +fi +for command in curl unzip sha256sum; do + command -v "$command" >/dev/null || { echo "missing required command: $command" >&2; exit 2; } +done +if [ "$selection" = all ]; then + command -v tar >/dev/null || { echo "missing required command: tar" >&2; exit 2; } +fi + +destination="$1" +mkdir -p "$destination" +work="$(mktemp -d)" +cleanup() { rm -rf "$work"; } +trap cleanup EXIT + +AST_GREP_VERSION="0.45.0" +AST_GREP_SHA256="78931ae35ebac33d9a72b3aecea3e3d62d6e5b0b718ac8bbedfbe69d68421e41" +AST_GREP_URL="https://github.com/ast-grep/ast-grep/releases/download/${AST_GREP_VERSION}/app-x86_64-unknown-linux-gnu.zip" +TRIVY_VERSION="0.74.0" +TRIVY_SHA256="2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a" +TRIVY_URL="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" + +download_verified() { + local url="$1" expected="$2" output="$3" + curl --fail --location --silent --show-error --retry 3 --retry-all-errors "$url" --output "$output" + printf '%s %s\n' "$expected" "$output" | sha256sum --check --status || { + echo "checksum mismatch for $url" >&2 + exit 2 + } +} + +download_verified "$AST_GREP_URL" "$AST_GREP_SHA256" "$work/ast-grep.zip" +unzip -q "$work/ast-grep.zip" -d "$work/ast-grep" +ast_binary="$(find "$work/ast-grep" -type f -name ast-grep -print -quit)" +if [ -z "$ast_binary" ]; then + echo "ast-grep binary missing from verified archive" >&2 + exit 2 +fi +install -m 0755 "$ast_binary" "$destination/ast-grep" +"$destination/ast-grep" --version +if [ "$selection" = ast-grep ]; then exit 0; fi + +download_verified "$TRIVY_URL" "$TRIVY_SHA256" "$work/trivy.tar.gz" +tar -xzf "$work/trivy.tar.gz" -C "$work" +if [ ! -f "$work/trivy" ]; then + echo "Trivy binary missing from verified archive" >&2 + exit 2 +fi +install -m 0755 "$work/trivy" "$destination/trivy" +"$destination/trivy" --version diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/qualify-ast-grep.mjs b/.github/vendor/labpics-code-admission/plans/tools/code-admission/qualify-ast-grep.mjs new file mode 100644 index 00000000..edd06d86 --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/qualify-ast-grep.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { fileDigest, prepareQualifiedExecutable } from "./identity.mjs"; + +const MAX_OUTPUT = 8 * 1024 * 1024; +const sha256 = value => createHash("sha256").update(value).digest("hex"); +const EXTENSIONS = Object.freeze({ rust: "rs", go: "go", typescript: "ts", javascript: "js" }); + +const argument = (name) => { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; + +const runPattern = ({ runtime, rule, path, environment }) => { + const result = runtime.run([ + "run", + "--pattern", rule.pattern, + "--lang", rule.language, + "--json=compact", + "--color", "never", + path, + ], { + encoding: "utf8", + maxBuffer: MAX_OUTPUT, + stdio: ["ignore", "pipe", "pipe"], + env: environment, + }); + if (result.error) throw new Error(`${rule.id}: ast-grep failed to start: ${result.error.message}`); + if (![0, 1].includes(result.status)) { + throw new Error(`${rule.id}: ast-grep exited ${result.status}: ${String(result.stderr).trim().slice(0, 2000)}`); + } + try { + return JSON.parse(result.stdout || "[]"); + } catch (error) { + throw new Error(`${rule.id}: ast-grep returned invalid JSON: ${error.message}`); + } +}; + +const astGrep = argument("--ast-grep"); +const rulesPath = argument("--rules"); +if (!astGrep || !rulesPath) { + console.error("usage: qualify-ast-grep.mjs --ast-grep --rules "); + process.exit(2); +} + +let root, runtime; +try { + const binary = resolve(astGrep), rulesFile = resolve(rulesPath); + const binaryDigest = fileDigest(binary); + const receipts = []; + const rulesRaw = readFileSync(rulesFile, "utf8"), rulesDigest = sha256(rulesRaw); + const rules = JSON.parse(rulesRaw); + if (!Array.isArray(rules) || rules.length === 0) throw new Error("rules must be a non-empty array"); + root = mkdtempSync(join(tmpdir(), "labpics-ast-qualification-")); + runtime = prepareQualifiedExecutable(binary, binaryDigest, root, "ast-grep qualification binary"); + const environment = { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: root, TMPDIR: root, LANG: "C.UTF-8", LC_ALL: "C.UTF-8", NO_COLOR: "1" }; + const ids = new Set(); + + for (const rule of rules) { + const id = String(rule?.id ?? ""); + const language = String(rule?.language ?? "").toLowerCase(); + const extension = EXTENSIONS[language]; + if (!/^lab-[a-z0-9-]+$/.test(id)) throw new Error(`invalid rule id: ${id}`); + if (ids.has(id)) throw new Error(`duplicate rule id: ${id}`); + if (!extension) throw new Error(`${id}: unsupported language ${language}`); + if (typeof rule.pattern !== "string" || rule.pattern.length === 0) throw new Error(`${id}: pattern missing`); + if (typeof rule.positive !== "string" || rule.positive.length === 0) throw new Error(`${id}: positive contrast missing`); + if (typeof rule.negative !== "string" || rule.negative.length === 0) throw new Error(`${id}: negative contrast missing`); + if (rule.positive === rule.negative) throw new Error(`${id}: contrasts must differ`); + ids.add(id); + + const positivePath = join(root, `${id}.positive.${extension}`); + const negativePath = join(root, `${id}.negative.${extension}`); + writeFileSync(positivePath, rule.positive); + writeFileSync(negativePath, rule.negative); + + const positive = runPattern({ runtime, rule: { ...rule, language }, path: positivePath, environment }); + const negative = runPattern({ runtime, rule: { ...rule, language }, path: negativePath, environment }); + if (!Array.isArray(positive) || positive.length === 0) { + throw new Error(`${id}: positive contrast was not detected`); + } + if (!Array.isArray(negative) || negative.length !== 0) { + throw new Error(`${id}: negative contrast was incorrectly detected`); + } + receipts.push({ ruleId: id, positive: positive.length, negative: 0 }); + console.error(`${id}: positive=${positive.length} negative=0`); + } + console.log(JSON.stringify({ schema: "labpics.ast-grep/native-qualification/v1", binaryDigest, rulesDigest, rules: receipts })); +} catch (error) { + console.error(`ast-grep qualification failure: ${error.message}`); + process.exitCode = 1; +} finally { if (runtime) runtime.close(); if (root) rmSync(root, { recursive: true, force: true }); } diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/qualify-trivy.mjs b/.github/vendor/labpics-code-admission/plans/tools/code-admission/qualify-trivy.mjs new file mode 100644 index 00000000..a177b9d5 --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/qualify-trivy.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { collectBypassFindings } from '../code-admission.mjs'; +import { normalizeTrivyReport, blockingSecurityFindings, scannerEnvironment, fileDigest } from './trivy.mjs'; +import { prepareQualifiedExecutable } from './identity.mjs'; + +// These tiny subjects qualify the actual binary and its embedded checks without +// downloading a vulnerability database or scanning product source twice. +export function qualifyTrivy(executable, parent = tmpdir()) { + const work = mkdtempSync(join(parent, 'trivy-controls-')); + const binary = resolve(executable), binaryDigest = fileDigest(binary); + const environment = scannerEnvironment(join(work, 'home'), work); + mkdirSync(environment.HOME); + const config = join(work, 'trusted.yml'); writeFileSync(config, '{}\n'); + const runtime = prepareQualifiedExecutable(binary, binaryDigest, work, 'Trivy qualification binary'); + const cases = [ + { name: 'lawful', text: 'FROM alpine:3.22\nUSER 65534\nWORKDIR /app\nHEALTHCHECK CMD exit 0\n', allowed: true }, + { name: 'unsafe', text: 'FROM alpine:3.22\nWORKDIR relative\n', allowed: false }, + { name: 'suppressed', text: 'FROM alpine:3.22\nUSER 65534\n#trivy:ignore:DS009\nWORKDIR relative\nHEALTHCHECK CMD exit 0\n', allowed: false }, + ]; + try { + const receipts = cases.map(subject => { + const root = join(work, subject.name); mkdirSync(root); writeFileSync(join(root, 'Dockerfile'), subject.text); + const execution = runtime.run(['config', '--config', config, '--ignorefile', '/dev/null', '--format', 'json', '--exit-code', '0', + '--include-non-failures', '--skip-check-update', '--cache-dir', join(work, 'cache'), '--quiet', root], + { cwd: work, env: environment, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 90000, stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(execution.error, undefined, 'native Trivy qualification did not finish'); + assert.equal(execution.status, 0, 'native Trivy qualification did not execute'); + const raw = JSON.parse(execution.stdout); + const findings = normalizeTrivyReport(raw, root); + const controls = collectBypassFindings(root); + const blockers = [...blockingSecurityFindings(findings), ...controls.filter(item => item.kind === 'scanner-control')]; + assert.equal(blockers.length === 0, subject.allowed, `native ${subject.name} qualification`); + if (subject.name === 'unsafe') { + assert.ok(blockers.some(item => item.rule === 'DS-0002'), 'non-root detector must fire'); + assert.ok(blockers.some(item => item.rule === 'DS-0009'), 'relative WORKDIR detector must fire'); + } + if (subject.name === 'suppressed') assert.ok(controls.some(item => item.rule === 'lab-bypass-trivy-inline')); + return { subject: subject.name, allowed: subject.allowed, blockingRules: blockers.map(item => item.rule).sort() }; + }); + return { schema: 'labpics.trivy/native-qualification/v1', binaryDigest, cases: receipts }; + } finally { runtime.close(); rmSync(work, { recursive: true, force: true }); } +} +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + if (process.argv.length !== 3) throw new TypeError('usage: qualify-trivy.mjs '); + process.stdout.write(`${JSON.stringify(qualifyTrivy(process.argv[2]))}\n`); + } catch (error) { console.error(`Trivy qualification failed: ${error.message}`); process.exitCode = 2; } +} diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/reject-symlinks.sh b/.github/vendor/labpics-code-admission/plans/tools/code-admission/reject-symlinks.sh new file mode 100644 index 00000000..8cb6759e --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/reject-symlinks.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "usage: reject-symlinks.sh [tree...]" >&2 + exit 2 +fi + +for root in "$@"; do + if [ ! -d "$root" ]; then + echo "scan tree is not a directory: $root" >&2 + exit 2 + fi + link="$(find "$root" -path "$root/.git" -prune -o -type l -print -quit)" + if [ -n "$link" ]; then + relative="${link#"$root/"}" + echo "symbolic link is not admitted to host filesystem scan: $relative" >&2 + exit 1 + fi +done diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/rules.json b/.github/vendor/labpics-code-admission/plans/tools/code-admission/rules.json new file mode 100644 index 00000000..2c9f6bc3 --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/rules.json @@ -0,0 +1,34 @@ +[ + { + "id": "lab-rust-unwrap", + "language": "rust", + "pattern": "$VALUE.unwrap()", + "message": "Моделируйте fallible-состояние явно вместо вызова unwrap().", + "positive": "fn f(result: Result<(), ()>) { result.unwrap(); }", + "negative": "fn f(result: Result<(), ()>) -> Result<(), ()> { result?; Ok(()) }" + }, + { + "id": "lab-rust-todo", + "language": "rust", + "pattern": "todo!($$$ARGS)", + "message": "todo!() оставляет незавершённое исполняемое поведение.", + "positive": "fn f() { todo!(); }", + "negative": "fn f() {}" + }, + { + "id": "lab-rust-unimplemented", + "language": "rust", + "pattern": "unimplemented!($$$ARGS)", + "message": "unimplemented!() оставляет незавершённое исполняемое поведение.", + "positive": "fn f() { unimplemented!(); }", + "negative": "fn f() {}" + }, + { + "id": "lab-go-panic", + "language": "go", + "pattern": "panic($VALUE)", + "message": "Предпочитайте явную ошибку или доказанное невозможное состояние вместо panic().", + "positive": "package main\nfunc f() { panic(\"boom\") }\n", + "negative": "package main\nfunc f() error { return nil }\n" + } +] diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/snapshot.mjs b/.github/vendor/labpics-code-admission/plans/tools/code-admission/snapshot.mjs new file mode 100644 index 00000000..097ab667 --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/snapshot.mjs @@ -0,0 +1,33 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { GitReader } from '../../../architecture/git.mjs'; +import { digest } from '../../../architecture/model.mjs'; + +// Git, а не mutable checkout, определяет каждый байт сканируемого предмета. +export function materializeSnapshot(repository, ref, destination) { + const git = new GitReader(repository), commit = git.oid(ref); + const entries = git.tree(commit); + if (entries.length > 30000) throw new Error('Security snapshot exceeds the 30000-file budget'); + let bytes = 0; + for (const entry of entries) { + if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode)) throw new Error('Security snapshot refuses symlink/submodule indirection'); + bytes += entry.size; + if (entry.size > 64 * 1024 * 1024 || bytes > 512 * 1024 * 1024) throw new Error('Security snapshot exceeds the byte budget'); + } + mkdirSync(destination, { recursive: false, mode: 0o700 }); + let batch = [], size = 0; + const flush = () => { + for (const [path, content] of git.blobs(batch)) { + const target = join(destination, path); + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + writeFileSync(target, content, { flag: 'wx', mode: 0o400 }); + } + batch = []; size = 0; + }; + for (const entry of entries) { + if (size + entry.size > 64 * 1024 * 1024) flush(); + batch.push(entry); size += entry.size; + } + flush(); + return { commit, tree: git.text(['rev-parse', `${commit}^{tree}`]), inventoryDigest: digest(entries), files: entries.length, bytes }; +} diff --git a/.github/vendor/labpics-code-admission/plans/tools/code-admission/trivy.mjs b/.github/vendor/labpics-code-admission/plans/tools/code-admission/trivy.mjs new file mode 100644 index 00000000..f83ac456 --- /dev/null +++ b/.github/vendor/labpics-code-admission/plans/tools/code-admission/trivy.mjs @@ -0,0 +1,166 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { stableJson } from '../../../architecture/model.mjs'; + +import { fileDigest, prepareQualifiedExecutable } from './identity.mjs'; +export { fileDigest } from './identity.mjs'; + +const MAX_OUTPUT = 64 * 1024 * 1024; +const SEVERITIES = new Set(['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']); +const sha256 = value => createHash('sha256').update(value).digest('hex'); +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +function text(value, label) { + if (typeof value !== 'string' || !value || value.length > 8192 || /[\x00-\x1f\x7f]/.test(value)) throw new TypeError(`Invalid Trivy ${label}`); + return value; +} +function array(value, label) { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new TypeError(`Invalid Trivy ${label}`); + return value; +} +function severity(item) { + if (!object(item) || !SEVERITIES.has(item.Severity)) throw new TypeError('Unknown Trivy finding severity'); + return item.Severity; +} +function line(value) { + if (value === undefined || value === 0) return 1; + if (!Number.isSafeInteger(value) || value < 1) throw new TypeError('Invalid Trivy source line'); + return value; +} +export function trivyTarget(root, target) { + text(target, 'target'); + const resolved = isAbsolute(target) ? resolve(target) : resolve(root, target); + const result = relative(resolve(root), resolved); + if (isAbsolute(result) || result === '..' || result.startsWith(`..${sep}`)) throw new TypeError('Trivy target is outside the scanned snapshot'); + return result.split(sep).join('/') || '.'; +} +export function validateTrivyReport(report, root) { + if (!object(report) || report.SchemaVersion !== 2 || report.ArtifactType !== 'filesystem' || report.ArtifactName !== resolve(root)) throw new TypeError('Trivy report schema or artifact identity mismatch'); + if (report.Metadata !== undefined && !object(report.Metadata)) throw new TypeError('Invalid Trivy metadata'); + for (const result of array(report.Results, 'Results')) { + if (!object(result)) throw new TypeError('Invalid Trivy result'); + trivyTarget(root, result.Target); + if (result.Class !== undefined && !['os-pkgs', 'lang-pkgs', 'config', 'secret', 'license', 'license-file'].includes(result.Class)) throw new TypeError('Unqualified Trivy result class'); + for (const key of ['Vulnerabilities', 'Secrets', 'Misconfigurations', 'Packages', 'Licenses', 'ExperimentalModifiedFindings']) array(result[key], key); + if ((result.ExperimentalModifiedFindings ?? []).length) throw new TypeError('Modified Trivy findings require independent review'); + if (result.CustomResources !== undefined && array(result.CustomResources, 'CustomResources').length) throw new TypeError('Unqualified custom scanner evidence'); + if (result.MisconfSummary !== undefined) { + const summary = result.MisconfSummary; + if (!object(summary) || !Number.isSafeInteger(summary.Successes) || summary.Successes < 0 || !Number.isSafeInteger(summary.Failures) || summary.Failures < 0) throw new TypeError('Invalid Trivy misconfiguration summary'); + const successes = (result.Misconfigurations ?? []).filter(item => item?.Status === 'PASS').length; + const failures = (result.Misconfigurations ?? []).filter(item => item?.Status === 'FAIL').length; + if (successes !== summary.Successes || failures !== summary.Failures) throw new TypeError('Trivy summary disagrees with complete finding population'); + } + } + return report; +} +export function normalizeTrivyReport(report, root) { + validateTrivyReport(report, root); + const findings = []; + for (const result of report.Results ?? []) { + const path = trivyTarget(root, result.Target); + for (const item of result.Vulnerabilities ?? []) { + const level = severity(item), id = text(item.VulnerabilityID, 'vulnerability ID'); + const pkg = text(item.PkgName, 'package'), version = text(item.InstalledVersion, 'package version'); + findings.push({ fingerprint: `trivy:vulnerability:${path}:${id}:${pkg}:${version}`, source: 'trivy', kind: 'vulnerability', severity: level, rule: id, path, line: 1, + package: pkg, installedVersion: version, fixedVersion: item.FixedVersion ?? null, + message: `${id}: ${pkg}@${version} (${level})` }); + } + for (const item of result.Secrets ?? []) { + const level = severity(item), id = text(item.RuleID, 'secret rule'); + const material = item.Match ?? item.Code?.Lines; + if (material === undefined) throw new TypeError('Trivy secret has no identity evidence'); + findings.push({ fingerprint: `trivy:secret:${path}:${id}:${sha256(stableJson(material))}`, source: 'trivy', kind: 'secret', severity: level, rule: id, path, line: line(item.StartLine), + message: `${id}: обнаружено совпадение с детектором секрета` }); + } + for (const item of result.Misconfigurations ?? []) { + const level = severity(item), id = text(item.ID ?? item.AVDID, 'misconfiguration ID'); + if (!['PASS', 'FAIL', 'EXCEPTION'].includes(item.Status)) throw new TypeError('Unknown Trivy misconfiguration status'); + if (item.Status === 'PASS') continue; + const title = text(item.Title, 'misconfiguration title'); + findings.push({ fingerprint: `trivy:misconfiguration:${path}:${id}:${sha256(title)}`, source: 'trivy', kind: 'misconfiguration', severity: level, rule: id, path, + line: line(item.CauseMetadata?.StartLine), suppressed: item.Status === 'EXCEPTION', message: `${id}: ${title}` }); + } + } + return findings; +} +export function blockingSecurityFindings(findings) { + return findings.filter(item => item.kind === 'secret' || item.suppressed || ['HIGH', 'CRITICAL', 'UNKNOWN'].includes(item.severity)); +} +export function scannerEnvironment(home, temporary, inherited = process.env) { + // Ни TRIVY_*, ни credentials, ни NODE_OPTIONS не пересекают границу сканера. + return { PATH: inherited.PATH ?? '/usr/local/bin:/usr/bin:/bin', HOME: home, TMPDIR: temporary, LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', NO_COLOR: '1' }; +} +export function databaseEvidence(cache, now = Date.now()) { + const metadata = JSON.parse(readFileSync(join(cache, 'db/metadata.json'), 'utf8')); + const updated = Date.parse(metadata.UpdatedAt), next = Date.parse(metadata.NextUpdate); + if (metadata.Version !== 2 || !Number.isFinite(updated) || !Number.isFinite(next) || updated > now + 300000 || next <= updated || now - updated > 24 * 60 * 60 * 1000 || now > next) throw new Error('Trivy vulnerability database is stale or invalid'); + const database = join(cache, 'db/trivy.db'); + if (!statSync(database).isFile() || statSync(database).size === 0) throw new Error('Trivy vulnerability database is absent'); + return { schemaVersion: metadata.Version, updatedAt: metadata.UpdatedAt, nextUpdate: metadata.NextUpdate, digest: fileDigest(database) }; +} +function execute(runtime, args, context, json = true) { + const result = runtime.run(args, { cwd: context.directory, env: context.environment, encoding: 'utf8', maxBuffer: MAX_OUTPUT, timeout: 360000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.error || result.status !== 0) { + // stderr может содержать исходный секрет; в ошибке только безопасная квитанция. + throw new Error(`Trivy execution incomplete: status=${result.status ?? 'none'} code=${result.error?.code ?? 'none'} diagnosticDigest=${sha256(result.stderr ?? '')}`); + } + if (!json) return result.stdout; + if (!result.stdout?.trim()) throw new Error('Trivy execution returned an empty report'); + try { return JSON.parse(result.stdout); } catch { throw new Error('Trivy execution returned malformed JSON'); } +} +export function createTrivySession(executable, parent, qualifiedBinaryDigest) { + const binaryDigest = qualifiedBinaryDigest; + mkdirSync(parent, { recursive: true }); + const directory = mkdtempSync(join(parent, 'qualified-trivy-')); + const home = join(directory, 'home'), temporary = join(directory, 'tmp'), cache = join(directory, 'cache'); + for (const path of [home, temporary, cache]) mkdirSync(path, { mode: 0o700 }); + const config = join(directory, 'trusted-empty.yaml'); writeFileSync(config, '{}\n', { mode: 0o600 }); + const context = { directory, environment: scannerEnvironment(home, temporary) }; + // Исполнение идёт через анонимный файловый дескриптор проверенной копии: замена пути + // после квалификации не меняет байты, которые получает execve. + const runtime = prepareQualifiedExecutable(executable, qualifiedBinaryDigest, directory, 'Trivy binary'); + const checkedExecute = (args, json = true) => execute(runtime, args, context, json); + let firstDatabase = null, prepared = false; + return { + scan(root, { skipUpdate = false } = {}) { + runtime.verify(); + if (skipUpdate && !firstDatabase) throw new Error('Candidate scan requires a qualified database from baseline'); + if (skipUpdate && stableJson(databaseEvidence(cache)) !== stableJson(firstDatabase)) throw new Error('Trivy database changed between subjects'); + if (!prepared) { + checkedExecute(['image', '--config', config, '--download-db-only', '--cache-dir', cache, '--disable-telemetry', '--quiet', '--timeout', '5m'], false); + // Bind the downloaded DB before the first scan, not afterwards. + // Otherwise replacement during the first candidate execution becomes + // the supposed baseline and silently receives a valid receipt. + firstDatabase = databaseEvidence(cache); + prepared = true; + } + const started = Date.now(); + const args = ['fs', '--config', config, '--secret-config', config, '--ignorefile', '/dev/null', '--format', 'json', '--exit-code', '0', '--scanners', 'vuln,secret,misconfig,license', + '--severity', 'UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL', '--include-dev-deps', '--include-non-failures', '--list-all-pkgs', '--offline-scan', '--parallel', '2', '--timeout', '5m', '--cache-dir', cache, + '--disable-telemetry', '--skip-version-check', '--show-suppressed', '--skip-db-update', '--skip-java-db-update', '--skip-check-update', '--skip-vex-repo-update', '--quiet']; + args.push(resolve(root)); + const raw = checkedExecute(args); + validateTrivyReport(raw, resolve(root)); + if (typeof raw.Trivy?.Version !== 'string' || !Number.isFinite(Date.parse(raw.CreatedAt)) || Date.parse(raw.CreatedAt) < started - 300000 || Date.parse(raw.CreatedAt) > Date.now() + 300000) throw new Error('Trivy report lacks current execution provenance'); + const database = databaseEvidence(cache); + if (firstDatabase && stableJson(database) !== stableJson(firstDatabase)) throw new Error('Trivy database drift invalidates comparison'); + firstDatabase ??= database; + const findings = normalizeTrivyReport(raw, resolve(root)); + const packageInventory = (raw.Results ?? []).flatMap(result => (result.Packages ?? []).map(pkg => ({ target: trivyTarget(root, result.Target), name: pkg.Name, version: pkg.Version ?? null, purl: pkg.Identifier?.PURL ?? null, licenses: pkg.Licenses ?? [] }))); + // SBOM строится из того же отчёта, без повторного сканирования исходников. + const rawPath = join(directory, 'convert.json'); writeFileSync(rawPath, JSON.stringify(raw), { mode: 0o600 }); + let sbom; + try { sbom = checkedExecute(['convert', '--config', config, '--ignorefile', '/dev/null', '--format', 'cyclonedx', '--quiet', rawPath]); } + finally { rmSync(rawPath, { force: true }); } + if (stableJson(databaseEvidence(cache)) !== stableJson(firstDatabase)) throw new Error('Trivy database drift invalidates SBOM evidence'); + if (sbom.bomFormat !== 'CycloneDX' || !Array.isArray(sbom.components ?? [])) throw new Error('Trivy returned an invalid SBOM'); + return { findings, sbom, evidence: { tool: { name: 'trivy', version: raw.Trivy.Version, binaryDigest }, reportDigest: sha256(stableJson(raw)), database, + artifact: { name: raw.ArtifactName, type: raw.ArtifactType }, startedAt: new Date(started).toISOString(), completedAt: new Date().toISOString(), + scanners: ['vuln', 'secret', 'misconfig', 'license'], checks: 'embedded-in-pinned-binary', packageInventory, configTargets: (raw.Results ?? []).filter(r => r.Class === 'config').map(r => trivyTarget(root, r.Target)), reportedTargets: (raw.Results ?? []).map(r => trivyTarget(root, r.Target)), + limitation: 'Наблюдение поддержанных Trivy форматов, не доказательство отсутствия неизвестных уязвимостей или runtime-достижимости.' } }; + }, + close() { runtime.close(); rmSync(directory, { recursive: true, force: true }); }, + }; +} diff --git a/.github/vendor/labpics-code-admission/source.json b/.github/vendor/labpics-code-admission/source.json new file mode 100644 index 00000000..360cf1f9 --- /dev/null +++ b/.github/vendor/labpics-code-admission/source.json @@ -0,0 +1,59 @@ +{ + "schema": "labpics.code-admission/public-carrier/v1", + "sourceRepository": "Labpics-Team/agents-config", + "sourceCommit": "1099dd68ce3ad697274bc2ea707837607b60283c", + "files": [ + { + "path": ".github/actions/code-admission/action.yml", + "blob": "71f83e3dacaf218e134da8d0427fea5c95fc2d77" + }, + { + "path": "architecture/git.mjs", + "blob": "d36e639e4656fff216dfcacf607773388a9c4149" + }, + { + "path": "architecture/model.mjs", + "blob": "ce4ca8e8dc93f3773f9171108e27ccf6a7719d11" + }, + { + "path": "plans/tools/code-admission.mjs", + "blob": "1539978d325292a8614a806ffa957c60dd05f702" + }, + { + "path": "plans/tools/code-admission/controls.mjs", + "blob": "dc4a37eb1920a7ff6af6427d81e6bc208a55dd67" + }, + { + "path": "plans/tools/code-admission/identity.mjs", + "blob": "33dea8f91ab5d4e3ffc90f4226ad960a5f5f5018" + }, + { + "path": "plans/tools/code-admission/install-tools.sh", + "blob": "76b4a3c0fa95689a0c953cc5044b2651906b5671" + }, + { + "path": "plans/tools/code-admission/qualify-ast-grep.mjs", + "blob": "edd06d867cabad83880e2c9122d603f12ff33594" + }, + { + "path": "plans/tools/code-admission/qualify-trivy.mjs", + "blob": "a177b9d5a2770b6b95c95ef5afc3fbe0acde9e0a" + }, + { + "path": "plans/tools/code-admission/reject-symlinks.sh", + "blob": "8cb6759e987ef4ad259c6c0203cb1feb11915371" + }, + { + "path": "plans/tools/code-admission/rules.json", + "blob": "2c9f6bc3ec07cb5f6bbe8daf8ece00666a4a0757" + }, + { + "path": "plans/tools/code-admission/snapshot.mjs", + "blob": "097ab6678f7228176ee3778004e62f047e32af56" + }, + { + "path": "plans/tools/code-admission/trivy.mjs", + "blob": "f83ac456ce9fe21cff1a0db2e176b93aac344214" + } + ] +} diff --git a/.github/vendor/labpics-code-admission/verify.mjs b/.github/vendor/labpics-code-admission/verify.mjs new file mode 100644 index 00000000..578f2544 --- /dev/null +++ b/.github/vendor/labpics-code-admission/verify.mjs @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative, resolve, sep } from 'node:path'; +const REPO='Labpics-Team/agents-config', COMMIT='1099dd68ce3ad697274bc2ea707837607b60283c', EXPECTED=[".github/actions/code-admission/action.yml","architecture/git.mjs","architecture/model.mjs","plans/tools/code-admission.mjs","plans/tools/code-admission/controls.mjs","plans/tools/code-admission/identity.mjs","plans/tools/code-admission/install-tools.sh","plans/tools/code-admission/qualify-ast-grep.mjs","plans/tools/code-admission/qualify-trivy.mjs","plans/tools/code-admission/reject-symlinks.sh","plans/tools/code-admission/rules.json","plans/tools/code-admission/snapshot.mjs","plans/tools/code-admission/trivy.mjs"]; +function walk(root,dir=root){const out=[];for(const e of readdirSync(dir,{withFileTypes:true})){const a=join(dir,e.name),s=lstatSync(a);if(s.isSymbolicLink())throw Error('Carrier symlinks are forbidden');if(s.isDirectory())out.push(...walk(root,a));else if(s.isFile())out.push(relative(root,a).split(sep).join('/'));else throw Error('Unsupported carrier entry');}return out;} +const blobId=b=>createHash('sha1').update(`blob ${b.length}\0`).update(b).digest('hex'); +function main(argv){if(argv.length!==2||argv[0]!=='--root')throw TypeError('usage: verify.mjs --root DIRECTORY');const root=resolve(argv[1]),r=JSON.parse(readFileSync(join(root,'source.json'),'utf8'));if(r?.schema!=='labpics.code-admission/public-carrier/v1'||r.sourceRepository!==REPO||r.sourceCommit!==COMMIT||!Array.isArray(r.files)||r.files.length!==EXPECTED.length)throw Error('Carrier origin receipt is invalid');const exp=new Set(EXPECTED),seen=new Set();for(const i of r.files){if(!i||!exp.has(i.path)||seen.has(i.path)||!/^[a-f0-9]{40}$/.test(i.blob))throw Error('Carrier receipt member is invalid');seen.add(i.path);const a=join(root,i.path),s=lstatSync(a);if(!s.isFile()||s.isSymbolicLink())throw Error('Carrier member is not a regular file');if(blobId(readFileSync(a))!==i.blob)throw Error('Carrier drift: '+i.path);}const actual=walk(root).sort(),required=[...EXPECTED,'source.json','verify.mjs'].sort();if(JSON.stringify(actual)!==JSON.stringify(required))throw Error('Carrier contains missing or unexpected files');console.log(JSON.stringify({repository:REPO,commit:COMMIT,files:EXPECTED.length}));} +try{main(process.argv.slice(2));}catch(e){console.error(String(e.message).replace(/[\x00-\x1f\x7f]/g,' '));process.exitCode=2;} diff --git a/.github/workflows/code-admission-public.yml b/.github/workflows/code-admission-public.yml new file mode 100644 index 00000000..18450be3 --- /dev/null +++ b/.github/workflows/code-admission-public.yml @@ -0,0 +1,55 @@ +name: code-admission-public +on: + pull_request: + merge_group: +permissions: + contents: read +concurrency: + group: labpics-public-admission-${{ github.repository }}-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.sha }} + cancel-in-progress: false +jobs: + admission: + name: code-admission-public + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout immutable carrier root + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.workflow_sha }} + fetch-depth: 1 + persist-credentials: false + submodules: false + lfs: false + - name: Select qualified Node runtime + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - name: Verify carrier provenance + shell: bash + run: | + set -euo pipefail + [[ "$(git rev-parse HEAD)" == "${{ github.workflow_sha }}" ]] + node .github/vendor/labpics-code-admission/verify.mjs --root .github/vendor/labpics-code-admission + - name: Checkout immutable integration candidate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + path: .code-subject.public01 + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + submodules: false + lfs: false + - name: Run qualified source admission + uses: ./.github/vendor/labpics-code-admission/.github/actions/code-admission + with: + repo: .code-subject.public01 + base: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + candidate: ${{ github.sha }} + - name: Remove candidate checkout + if: ${{ always() }} + shell: bash + run: | + set -euo pipefail + [[ -d .code-subject.public01 && ! -L .code-subject.public01 ]] + rm -rf -- .code-subject.public01 diff --git a/ci/workflows_test.go b/ci/workflows_test.go index 79cb1569..b0477761 100644 --- a/ci/workflows_test.go +++ b/ci/workflows_test.go @@ -87,9 +87,66 @@ func parse(raw []byte) (map[string]any, error) { //go:embed testdata/native-jobs.yml var nativeJobsYAML []byte +const publicAdmissionWorkflow = `name: code-admission-public +on: + pull_request: + merge_group: +permissions: + contents: read +concurrency: + group: labpics-public-admission-${{ github.repository }}-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.sha }} + cancel-in-progress: false +jobs: + admission: + name: code-admission-public + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout immutable carrier root + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.workflow_sha }} + fetch-depth: 1 + persist-credentials: false + submodules: false + lfs: false + - name: Select qualified Node runtime + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - name: Verify carrier provenance + shell: bash + run: | + set -euo pipefail + [[ "$(git rev-parse HEAD)" == "${{ github.workflow_sha }}" ]] + node .github/vendor/labpics-code-admission/verify.mjs --root .github/vendor/labpics-code-admission + - name: Checkout immutable integration candidate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + path: .code-subject.public01 + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + submodules: false + lfs: false + - name: Run qualified source admission + uses: ./.github/vendor/labpics-code-admission/.github/actions/code-admission + with: + repo: .code-subject.public01 + base: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + candidate: ${{ github.sha }} + - name: Remove candidate checkout + if: ${{ always() }} + shell: bash + run: | + set -euo pipefail + [[ -d .code-subject.public01 && ! -L .code-subject.public01 ]] + rm -rf -- .code-subject.public01 +` + func validate(files map[string][]byte) error { - if len(files) != 3 { - return fmt.Errorf("expected exactly the three native workflows") + if len(files) != 4 { + return fmt.Errorf("expected exactly three native workflows plus public code admission") } nativeJobs, err := parse(nativeJobsYAML) if err != nil { @@ -98,6 +155,17 @@ func validate(files map[string][]byte) error { if len(nativeJobs) != 3 { return fmt.Errorf("expected exactly three native job fixtures") } + publicAdmission, err := parse(files["code-admission-public.yml"]) + if err != nil { + return fmt.Errorf("code-admission-public.yml: %w", err) + } + expectedAdmission, err := parse([]byte(publicAdmissionWorkflow)) + if err != nil { + return fmt.Errorf("invalid public admission fixture: %w", err) + } + if !reflect.DeepEqual(publicAdmission, expectedAdmission) { + return fmt.Errorf("code-admission-public.yml: complete workflow contract changed") + } for _, file := range []string{"go_build.yml", "ginkgo_test.yml", "integration.yml"} { workflow, err := parse(files[file]) if err != nil {