diff --git a/CHANGELOG.md b/CHANGELOG.md index 684c6f0..2d1c144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Classify Admin, Storefront, Customer Account, and ambiguous GraphQL operations independently using nearby source context. +- Add explicit Shopify API version status and evidence applicability to CLI, Action, JSON, human, and SARIF reports. +- Make standalone GraphQL documents without API client context UNKNOWN and restrict explicit configuration scans to the selected app root. + ## 0.2.4 - Add versioned 2026-10 access-scope evidence for payment instrument email, billing fee details, market relationships, and documented Events topics. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c832f6c..506b328 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,3 +29,5 @@ Before opening a pull request, also run `npm run coverage`, `npm audit --omit=de - Use privacy-safe fixtures with no credentials, tokens, or merchant data. See the [open issues](https://github.com/RexCode-Digital/shopify-scope-guard/issues) for current opportunities. + +These repositories use a solo-maintainer review model. Keep changes in pull requests and merge only after the required automated checks pass. CODEOWNERS may route review requests, but human approval is not a merge requirement. diff --git a/README.md b/README.md index ce371e6..8419402 100644 --- a/README.md +++ b/README.md @@ -92,16 +92,19 @@ See the [GitHub Action guide](docs/github-action.md). | --- | --- | --- | | `path` | Repository path to scan | `.` | | `config` | Shopify app TOML path | auto-discovered | +| `api-version` | Admin GraphQL API version used for evidence applicability | `2026-10` | | `fail-on` | Minimum finding severity that fails the job | `high` | | `format` | Output format: `human`, `json`, or `sarif` | `human` | | `show-unmapped` | Show detailed unmapped patterns in human output | `false` | ### Action outputs -`outcome`, `finding-count`, `high-count`, `medium-count`, `low-count`, `unknown-count`, `missing-scope-count`, `redundant-scope-count`, and `report`. +`outcome`, `finding-count`, `high-count`, `medium-count`, `low-count`, `unknown-count`, `missing-scope-count`, `redundant-scope-count`, `api-version`, `api-version-status`, `evidence-version`, `evidence-applicable`, and `report`. The `report` output is a JSON report file in the runner temporary directory. SARIF format also writes `scope-guard.sarif` there. `outcome` reflects the selected `fail-on` policy. +The example above pins v0.2.4, which does not accept the `api-version` Action input or expose the version and evidence outputs listed above. Those additions and the `--api-version` CLI option below are part of the unreleased changes here; npm v0.2.4 does not support that option. + The Action is bundled and runs on GitHub's `node24` JavaScript Action runtime. Consumer jobs do not install Scope Guard dependencies separately. ## What it catches today @@ -138,7 +141,7 @@ See the [evidence model](docs/evidence-model.md). ## CLI ```text -shopify-scope-guard audit [--format human|json|sarif] [--fail-on ...] +shopify-scope-guard audit [--api-version YYYY-MM] [--format human|json|sarif] [--fail-on ...] shopify-scope-guard rules shopify-scope-guard explain SG-SCOPE-001 shopify-scope-guard --version @@ -156,6 +159,9 @@ npx shopify-scope-guard audit --format json # SARIF for code-scanning workflows npx shopify-scope-guard audit --format sarif +# Inspect scope evidence for the current release candidate +npx shopify-scope-guard audit --api-version 2027-01 --format json + # Fail when medium-or-higher findings are present npx shopify-scope-guard audit --fail-on medium @@ -166,6 +172,8 @@ npx shopify-scope-guard explain SG-SCOPE-001 The `--fail-on` option accepts `none`, `low`, `medium`, or `high`. A scan exits `1` when a finding meets the selected threshold, `0` when policy passes, and `2` for usage or scanner errors. +Reports identify the selected version's status, latest stable version, current release candidate, and whether the bundled evidence applies. The current pack is verified for `2026-10`; selecting `2027-01` or another version outside that pack leaves Admin operations UNKNOWN rather than applying mismatched rules. + See the [CLI reference](docs/cli.md). ## Configuration @@ -179,7 +187,7 @@ The CLI supports: Auto-discovery prefers `shopify.app.toml`, otherwise a single named root configuration. Missing or ambiguous configurations fail explicitly. For nested apps use `audit --path apps/my-app --config shopify.app.production.toml`. Scans are bounded to 2,000 code files and 1 MiB per file; symlinks and bounded-out files appear in `skipped` and require manual review. -Local fragments, inline fragments, and aliases are supported. Explicit Storefront signals stay outside Admin scope evidence. A standalone GraphQL document without a surface signal is assumed to be Admin; do not point an Admin audit at an unlabelled Storefront document set. +Local fragments, inline fragments, and aliases are supported. Admin, Storefront, and Customer Account operations are classified per GraphQL document when a nearby client signal is clear. Storefront operations stay outside Admin scope evidence; Customer Account and ambiguous operations are reported as UNKNOWN. Standalone `.graphql` documents without client context are UNKNOWN until their API surface can be established. GraphQL 16 is retained for Node 20 support. Dependabot PR #3 remains open: GraphQL 17.0.2 officially requires Node 22/24/25/26 and is incompatible with Node 20. The minimum Node version will not be raised to satisfy that update. @@ -222,6 +230,8 @@ Building or maintaining Shopify apps? - **[Shopify Upgrade Guard](https://github.com/RexCode-Digital/shopify-upgrade-guard)** — Catch documented Shopify API and platform upgrade risks before production migrations. - **[Shopify App Review Guard](https://github.com/RexCode-Digital/shopify-app-review-guard)** — Run deterministic preflight checks for Shopify App Store and production readiness. +See the [coordinated workflow example](https://github.com/RexCode-Digital/shopify-app-changeguard/blob/main/docs/ecosystem-workflow.md) to run all four published Actions in one pull-request workflow. + These are independent open-source tools and are not affiliated with Shopify. ## Contributing diff --git a/action.yml b/action.yml index 6b22f0e..103df5b 100644 --- a/action.yml +++ b/action.yml @@ -7,6 +7,7 @@ branding: inputs: path: { description: Repository path to scan, required: false, default: '.' } config: { description: Shopify app TOML path, required: false } + api-version: { description: Admin GraphQL API version selected for scope evidence, required: false, default: '2026-10' } fail-on: { description: Minimum finding severity that fails the job, required: false, default: high } format: { description: Output format human, json, or sarif, required: false, default: human } show-unmapped: { description: Include unsupported Shopify patterns, required: false, default: false } @@ -17,6 +18,10 @@ outputs: medium-count: { description: Medium findings } low-count: { description: Low findings } unknown-count: { description: Unknown code patterns } + api-version: { description: Selected Admin GraphQL API version } + api-version-status: { description: Shopify API version status: stable, release-candidate, unstable, or unverified } + evidence-version: { description: Bundled evidence version } + evidence-applicable: { description: Whether the evidence pack applies to the selected Admin GraphQL API version } missing-scope-count: { description: Missing or optional-required scope findings } redundant-scope-count: { description: Redundant scope findings } report: { description: JSON report path } diff --git a/dist/index.cjs b/dist/index.cjs index 74dda7d..8cca2f5 100644 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -22317,23 +22317,80 @@ function parseGraphQL(source, file, lineOffset = 0) { }); return document.definitions.filter((d) => d.kind === import_graphql.Kind.OPERATION_DEFINITION).map((node) => ({ type: node.operation, name: node.name?.value ?? null, fields: collectFields(node.selectionSet), file, line: (node.loc?.startToken?.line ?? 1) + lineOffset })); } +function maskComments(source) { + let output = "", quote = "", escaped = false; + for (let index = 0; index < source.length; index++) { + const character = source[index], next = source[index + 1]; + if (quote) { + output += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = ""; + continue; + } + if (character === '"' || character === "'" || character === String.fromCharCode(96)) { + quote = character; + output += character; + continue; + } + if (character === "/" && next === "/") { + output += " "; + index++; + while (index + 1 < source.length && source[index + 1] !== "\n") { + index++; + output += " "; + } + continue; + } + if (character === "/" && next === "*") { + output += " "; + index++; + while (index + 1 < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + index++; + output += source[index] === "\n" ? "\n" : " "; + } + if (index + 1 < source.length) { + index++; + output += " "; + } + continue; + } + output += character; + } + return output; +} function extractGraphQL(text, file) { - const documents = /* @__PURE__ */ new Map(); - if (/\.(graphql|gql)$/i.test(file)) documents.set(0, text); + const documents = []; + if (/\.(graphql|gql)$/i.test(file)) documents.push({ offset: 0, source: text, end: text.length }); else { const patterns = [/#graphql\s*([\s\S]*?)(?=`)/g, /(?:graphql|gql)\s*(?:\(\s*)?`([\s\S]*?)`/g, /query\s*[:=]\s*`([\s\S]*?)`/g]; for (const pattern of patterns) for (const match of text.matchAll(pattern)) { - const offset = match.index + match[0].indexOf(match[1]); - const source = match[1].replace(/^#graphql\s*/, ""); - if (![...documents.values()].some((d) => d.replace(/^#graphql\s*/, "") === source)) documents.set(offset, match[1]); + const raw = match[1], rawOffset = match.index + match[0].indexOf(raw); + const prefix = raw.match(/^#graphql\s*/)?.[0] ?? ""; + const source = raw.slice(prefix.length), offset = rawOffset + prefix.length; + if (!documents.some((document) => document.source === source)) documents.push({ offset, source, end: offset + source.length }); } } const result = []; - for (const [offset, source] of documents) { + for (const document of documents) { + const lineOffset = text.slice(0, document.offset).split("\n").length - 1; + let sourceContext = ""; + if (!/\.(graphql|gql)$/i.test(file)) { + const templateStart = text.lastIndexOf("`", document.offset); + const templateEnd = text.indexOf("`", document.end); + if (templateStart >= 0 && templateEnd >= 0) { + const statementStart = Math.max(text.lastIndexOf(";", templateStart) + 1, templateStart - 180); + const before = text.slice(statementStart, templateStart); + sourceContext = maskComments(before); + } + } try { - result.push(...parseGraphQL(source, file, text.slice(0, offset).split("\n").length - 1)); + result.push(...parseGraphQL(document.source, file, lineOffset).map((operation) => ({ + ...operation, + sourceContext + }))); } catch { - result.push({ type: "unknown", name: null, fields: [], file, line: text.slice(0, offset).split("\n").length, parseError: true }); + result.push({ type: "unknown", name: null, fields: [], file, line: text.slice(0, document.offset).split("\n").length, parseError: true, sourceContext: "" }); } } return result; @@ -22341,6 +22398,24 @@ function extractGraphQL(text, file) { // src/evidence/registry.js var EVIDENCE_VERSION = "2026-10"; +var API_VERSION_INTELLIGENCE = Object.freeze({ + latestStable: "2026-10", + releaseCandidate: "2027-01", + verifiedAt: "2026-10-14", + statuses: Object.freeze({ + "2025-04": "unsupported", + "2025-07": "unsupported", + "2025-10": "stable", + "2026-01": "stable", + "2026-04": "stable", + "2026-07": "stable", + "2026-10": "stable", + "2027-01": "release-candidate", + unstable: "unstable" + }), + accessibleUntil: Object.freeze({ "2025-10": "2026-10-16T15:00:00Z" }), + source: "https://shopify.dev/docs/api/usage/versioning" +}); var EVIDENCE_SOURCES = { scopes: "https://shopify.dev/docs/apps/build/authentication-authorization/manage-access-scopes", accessScopes: "https://shopify.dev/docs/api/usage/access-scopes", @@ -22479,43 +22554,88 @@ function walk(root, skipped, current = root, out = []) { function finding(ruleId, severity, confidence, scope, file, line, explanation, evidence, source, remediation) { return { ruleId, severity, confidence, scope, file: file ?? null, line: line ?? null, explanation, evidence, source, remediation }; } -function audit({ root = ".", configPath, include, exclude = [] } = {}) { - const absoluteRoot = import_node_path2.default.resolve(root); +function discoverAppConfigs(root, current = root, result = []) { + for (const entry of import_node_fs2.default.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith(".") || DEFAULT_IGNORES.has(entry.name)) continue; + const full = import_node_path2.default.join(current, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) discoverAppConfigs(root, full, result); + else if (entry.isFile() && /^shopify\.app(?:\..+)?\.toml$/.test(entry.name)) result.push(full); + } + return result; +} +function operationSurface(operation) { + const context = operation.sourceContext ?? ""; + const evidence = []; + if (/\badmin\s*\.\s*graphql\s*\(|\bauthenticate\s*\.\s*admin\b|\/admin\/api\/\d{4}-\d{2}\/graphql\.json|\bcurrentAppInstallation\b/i.test(context)) evidence.push("admin-graphql"); + if (/\bstorefront\s*\.\s*graphql\s*\(|\bunauthenticated\s*\.\s*storefront\b|storefront-access-token|https?:\/\/[^/\s]+\/api\/\d{4}-\d{2}\/graphql\.json/i.test(context)) evidence.push("storefront-graphql"); + if (/\bcustomerAccount\s*\.\s*graphql\s*\(|\bunauthenticated\s*\.\s*customerAccount\b|\/customer\/api\//i.test(context)) evidence.push("customer-account-graphql"); + return evidence.length === 1 ? evidence[0] : null; +} +function apiStatus(version, now = /* @__PURE__ */ new Date()) { + const status = API_VERSION_INTELLIGENCE.statuses[version]; + if (status === "stable") { + const accessibleUntil = API_VERSION_INTELLIGENCE.accessibleUntil[version]; + if (accessibleUntil && now.getTime() >= Date.parse(accessibleUntil)) return "unsupported"; + } + return status ?? "unverified"; +} +function audit({ root = ".", configPath, include, exclude = [], apiVersion = API_VERSION_INTELLIGENCE.latestStable, now = /* @__PURE__ */ new Date() } = {}) { + if (typeof apiVersion !== "string" || !/^(?:\d{4}-\d{2}|unstable)$/.test(apiVersion)) throw new Error("API version must use YYYY-MM or unstable"); + let absoluteRoot = import_node_path2.default.resolve(root); if (!import_node_fs2.default.lstatSync(absoluteRoot).isDirectory()) throw new Error("Scan root must be a regular directory"); - const candidates = import_node_fs2.default.readdirSync(absoluteRoot).filter((f) => /^shopify\.app(?:\..+)?\.toml$/.test(f)).sort(); - if (!configPath && candidates.length > 1 && !candidates.includes("shopify.app.toml")) throw new Error("Multiple app configurations found; select one with --config"); - const selected = configPath ?? (candidates.includes("shopify.app.toml") ? "shopify.app.toml" : candidates[0]); - if (!selected) throw new Error("Shopify app configuration not found; select one with --config"); - const configFile = import_node_path2.default.resolve(absoluteRoot, selected); - regularFile(absoluteRoot, configFile); + let configFile; + if (configPath) { + configFile = import_node_path2.default.resolve(absoluteRoot, configPath); + regularFile(absoluteRoot, configFile); + absoluteRoot = import_node_path2.default.dirname(configFile); + if (discoverAppConfigs(absoluteRoot).some((file) => import_node_path2.default.dirname(file) !== absoluteRoot)) throw new Error("Nested Shopify app roots found; select one with --path or --config"); + } else { + const candidates = import_node_fs2.default.readdirSync(absoluteRoot).filter((f) => /^shopify\.app(?:\..+)?\.toml$/.test(f)).sort(); + const nestedRoots = [...new Set(discoverAppConfigs(absoluteRoot).map((file) => import_node_path2.default.dirname(file)).filter((dir) => dir !== absoluteRoot))]; + if (nestedRoots.length) throw new Error("Nested Shopify app roots found; select one with --path or --config"); + if (candidates.length > 1 && !candidates.includes("shopify.app.toml")) throw new Error("Multiple app configurations found; select one with --config"); + const selected = candidates.includes("shopify.app.toml") ? "shopify.app.toml" : candidates[0]; + if (!selected) throw new Error("Shopify app configuration not found; select one with --config"); + configFile = import_node_path2.default.resolve(absoluteRoot, selected); + regularFile(absoluteRoot, configFile); + } const config = parseConfig(configFile); const skipped = []; const files = walk(absoluteRoot, skipped).filter((file) => !exclude.some((value) => file.includes(value)) && (!include || file.includes(include))); const observations = [], findings = [], unknown = []; for (const file of files) { const text = import_node_fs2.default.readFileSync(file, "utf8"); - const surface = /\/admin\/api\/[^/]+\/graphql\.json|admin\.graphql|authenticate\.admin|currentAppInstallation/.test(text) ? "admin-graphql" : /storefront|unauthenticated_read_/.test(text) ? "storefront" : /\.(graphql|gql)$/i.test(file) ? "admin-graphql" : null; - if (!surface && /graphql|shopify/i.test(text)) { - unknown.push({ file: import_node_path2.default.relative(absoluteRoot, file), reason: "Shopify-related code could not be assigned to a supported API surface." }); - continue; - } - if (surface === "storefront") continue; const operations = extractGraphQL(text, file); - if (surface === "admin-graphql" && operations.length === 0 && /(admin\.graphql|client\.(query|request)|graphql\s*\()/.test(text)) { - unknown.push({ file: import_node_path2.default.relative(absoluteRoot, file), line: 1, reason: "Shopify Admin GraphQL client usage was found, but the query was not a static document." }); + if (operations.length === 0 && /(?:admin|storefront|customerAccount)\.graphql|\/admin\/api\/|\/customer\/api\//i.test(text)) { + unknown.push({ file: import_node_path2.default.relative(absoluteRoot, file), line: 1, reason: "Shopify API client usage was found, but the query was not a static document." }); } for (const op of operations) { if (op.parseError) { unknown.push({ file: import_node_path2.default.relative(absoluteRoot, file), line: op.line, reason: "GraphQL-like text could not be parsed safely." }); continue; } + const surface = operationSurface(op); + if (!surface) { + unknown.push({ file: import_node_path2.default.relative(absoluteRoot, file), line: op.line, reason: "GraphQL operation could not be assigned to one supported API surface." }); + continue; + } + if (surface === "storefront-graphql") continue; + if (surface === "customer-account-graphql") { + unknown.push({ file: import_node_path2.default.relative(absoluteRoot, file), line: op.line, reason: "Customer Account GraphQL is identifiable but is outside the Admin GraphQL scope evidence pack." }); + continue; + } + if (apiVersion !== EVIDENCE_VERSION) { + unknown.push({ file: import_node_path2.default.relative(absoluteRoot, file), line: op.line, reason: `Admin GraphQL scope evidence is verified for ${EVIDENCE_VERSION} only; selected API version ${apiVersion} is ${apiStatus(apiVersion, now)}.` }); + continue; + } for (const field of op.fields) { const rules = rulesFor(field.path, op.type); if (!rules.length) { if (!field.path.includes(".") && !["shop", "__typename"].includes(field.path)) unknown.push({ file: import_node_path2.default.relative(absoluteRoot, file), line: field.line, reason: "GraphQL root operation is outside the bundled scope registry." }); continue; } - for (const item of rules) observations.push({ ...item, operationType: op.type, file, line: field.line, operationName: op.name }); + for (const item of rules) observations.push({ ...item, apiSurface: surface, operationType: op.type, file, line: field.line, operationName: op.name }); } } } @@ -22550,7 +22670,25 @@ function audit({ root = ".", configPath, include, exclude = [] } = {}) { for (const scope of config.optional) if (!observedScopes.has(scope)) findings.push(finding("SG-SCOPE-004", "low", "high", scope, import_node_path2.default.relative(absoluteRoot, configFile).replaceAll(import_node_path2.default.sep, "/"), null, `No supported usage requiring optional scope ${scope} was evidenced.`, "No matching supported operation found", EVIDENCE_SOURCES.scopes, `Review the optional feature path; this result does not prove the scope is unused.`)); if (unknown.length) findings.push(finding("SG-SCOPE-006", "info", "low", null, unknown[0].file, unknown[0].line, "Shopify-related code was found but could not be safely mapped to supported static evidence.", unknown.map((item) => item.reason).join("; "), EVIDENCE_SOURCES.scopes, "Review the unmapped code manually.")); findings.sort((a, b) => `${a.severity}:${a.ruleId}:${a.file}:${a.line}`.localeCompare(`${b.severity}:${b.ruleId}:${b.file}:${b.line}`)); - return { tool: { name: "shopify-scope-guard", version: TOOL_VERSION }, evidence: { api: "admin-graphql", version: EVIDENCE_VERSION, source: EVIDENCE_SOURCES.versioning }, summary: { declaredRequired: config.required.length, declaredOptional: config.optional.length, evidenced: observedScopes.size, unknown: unknown.length, findingCount: findings.length }, scopes: { required: config.required, optional: config.optional }, observations: observations.map((o) => ({ operation: o.operation, scope: o.requires.anyOf, file: import_node_path2.default.relative(absoluteRoot, o.file).replaceAll(import_node_path2.default.sep, "/"), line: o.line })), findings, unknown, skipped }; + return { + tool: { name: "shopify-scope-guard", version: TOOL_VERSION }, + evidence: { api: "admin-graphql", version: EVIDENCE_VERSION, source: EVIDENCE_SOURCES.versioning }, + apiVersion: { + selected: apiVersion, + status: apiStatus(apiVersion, now), + latestStable: API_VERSION_INTELLIGENCE.latestStable, + releaseCandidate: API_VERSION_INTELLIGENCE.releaseCandidate, + evidenceVersion: EVIDENCE_VERSION, + evidenceApplicable: apiVersion === EVIDENCE_VERSION, + source: API_VERSION_INTELLIGENCE.source + }, + summary: { declaredRequired: config.required.length, declaredOptional: config.optional.length, evidenced: observedScopes.size, unknown: unknown.length, findingCount: findings.length }, + scopes: { required: config.required, optional: config.optional }, + observations: observations.map((o) => ({ operation: o.operation, apiSurface: o.apiSurface ?? "events", scope: o.requires.anyOf, file: import_node_path2.default.relative(absoluteRoot, o.file).replaceAll(import_node_path2.default.sep, "/"), line: o.line })), + findings, + unknown, + skipped + }; } // src/output/index.js @@ -22559,7 +22697,9 @@ function toJson(report) { return JSON.stringify(report, null, 2) + "\n"; } function toHuman(report, { showUnmapped = false } = {}) { - const lines = ["Shopify Scope Guard", "", "Scope audit", "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", `Declared: required ${report.summary.declaredRequired}, optional ${report.summary.declaredOptional}`, `Observed: evidenced ${report.summary.evidenced}, unknown ${report.summary.unknown}`, `Findings: ${report.summary.findingCount}`, ""]; + const lines = ["Shopify Scope Guard", "", "Scope audit", "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"]; + if (report.apiVersion) lines.push(`Admin GraphQL target: ${report.apiVersion.selected} (${report.apiVersion.status}); bundled evidence: ${report.apiVersion.evidenceVersion} (${report.apiVersion.evidenceApplicable ? "applicable" : "not applicable"})`); + lines.push(`Declared: required ${report.summary.declaredRequired}, optional ${report.summary.declaredOptional}`, `Observed: evidenced ${report.summary.evidenced}, unknown ${report.summary.unknown}`, `Findings: ${report.summary.findingCount}`, ""); for (const f of [...report.findings].sort((a, b) => rank[b.severity] - rank[a.severity])) { lines.push(`${f.severity.toUpperCase()} ${f.ruleId}${f.scope ? ` \u2014 ${f.scope}` : ""}`, ` ${f.explanation}`, ` Evidence: ${f.evidence}`, ` Source: ${f.source}`, ` Location: ${f.file ?? "repository"}${f.line ? `:${f.line}` : ""}`, ` Recommendation: ${f.remediation}`, ""); } @@ -22568,7 +22708,7 @@ function toHuman(report, { showUnmapped = false } = {}) { return lines.join("\n"); } function toSarif(report) { - return JSON.stringify({ version: "2.1.0", $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [{ tool: { driver: { name: report.tool.name, version: report.tool.version, informationUri: "https://github.com/RexCode-Digital/shopify-scope-guard", rules: [...new Map(report.findings.map((f) => [f.ruleId, { id: f.ruleId, shortDescription: { text: f.explanation }, helpUri: f.source }])).values()] } }, results: report.findings.map((f) => ({ ruleId: f.ruleId, level: f.severity === "high" ? "error" : f.severity === "medium" ? "warning" : "note", message: { text: f.explanation }, locations: f.file ? [{ physicalLocation: { artifactLocation: { uri: f.file.split("/").map(encodeURIComponent).join("/") }, region: f.line ? { startLine: f.line } : void 0 } }] : void 0 })) }] }, null, 2) + "\n"; + return JSON.stringify({ version: "2.1.0", $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [{ tool: { driver: { name: report.tool.name, version: report.tool.version, informationUri: "https://github.com/RexCode-Digital/shopify-scope-guard", rules: [...new Map(report.findings.map((f) => [f.ruleId, { id: f.ruleId, shortDescription: { text: f.explanation }, helpUri: f.source }])).values()] } }, properties: { apiVersion: report.apiVersion }, results: report.findings.map((f) => ({ ruleId: f.ruleId, level: f.severity === "high" ? "error" : f.severity === "medium" ? "warning" : "note", message: { text: f.explanation }, locations: f.file ? [{ physicalLocation: { artifactLocation: { uri: f.file.split("/").map(encodeURIComponent).join("/") }, region: f.line ? { startLine: f.line } : void 0 } }] : void 0 })) }] }, null, 2) + "\n"; } // src/policy.js @@ -22588,7 +22728,7 @@ try { const format = input("format") || "human", threshold = input("fail-on") || "high"; validateOptions({ format, failOn: threshold }); if (input("show-unmapped") && !["true", "false"].includes(input("show-unmapped"))) throw new Error("show-unmapped must be true or false"); - const report = audit({ root: input("path") || ".", configPath: input("config") || void 0 }); + const report = audit({ root: input("path") || ".", configPath: input("config") || void 0, apiVersion: input("api-version") || void 0 }); const rendered = format === "json" ? toJson(report) : format === "sarif" ? toSarif(report) : toHuman(report, { showUnmapped: input("show-unmapped") === "true" }); const stop = (0, import_node_crypto.randomUUID)(); console.log(`::stop-commands::${stop} @@ -22600,7 +22740,7 @@ ${rendered} if (format === "sarif") import_node_fs3.default.writeFileSync(import_node_path3.default.join(directory, "scope-guard.sarif"), rendered); const counts = (severity) => report.findings.filter((f) => f.severity === severity).length; const failed = shouldFail(report, threshold); - const values = { outcome: failed ? "failed" : "passed", "finding-count": report.findings.length, "high-count": counts("high"), "medium-count": counts("medium"), "low-count": counts("low"), "unknown-count": report.summary.unknown, "missing-scope-count": report.findings.filter((f) => ["SG-SCOPE-001", "SG-SCOPE-002"].includes(f.ruleId)).length, "redundant-scope-count": report.findings.filter((f) => f.ruleId === "SG-SCOPE-003").length, report: reportPath }; + const values = { outcome: failed ? "failed" : "passed", "finding-count": report.findings.length, "high-count": counts("high"), "medium-count": counts("medium"), "low-count": counts("low"), "unknown-count": report.summary.unknown, "missing-scope-count": report.findings.filter((f) => ["SG-SCOPE-001", "SG-SCOPE-002"].includes(f.ruleId)).length, "redundant-scope-count": report.findings.filter((f) => f.ruleId === "SG-SCOPE-003").length, "api-version": report.apiVersion.selected, "api-version-status": report.apiVersion.status, "evidence-version": report.apiVersion.evidenceVersion, "evidence-applicable": report.apiVersion.evidenceApplicable, report: reportPath }; if (process.env.GITHUB_OUTPUT) for (const [key, value] of Object.entries(values)) { const delimiter = (0, import_node_crypto.randomUUID)(); import_node_fs3.default.appendFileSync(process.env.GITHUB_OUTPUT, `${key}<<${delimiter} diff --git a/docs/cli.md b/docs/cli.md index bbf0a15..0a74e75 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,13 +1,16 @@ # CLI -`shopify-scope-guard audit [path]` supports `--format human|json|sarif`, `--config`, `--fail-on none|low|medium|high`, `--path`, and `--include`. +`shopify-scope-guard audit [path]` supports `--format human|json|sarif`, `--config`, `--api-version YYYY-MM`, `--fail-on none|low|medium|high`, `--path`, and `--include`. ```sh shopify-scope-guard audit shopify-scope-guard audit examples/02-missing-scope --format json --fail-on high shopify-scope-guard audit --format sarif +shopify-scope-guard audit --api-version 2027-01 --format json ``` `shopify-scope-guard rules` prints the bundled evidence registry. `shopify-scope-guard explain SG-SCOPE-001` prints matching evidence entries. `shopify-scope-guard version` and `shopify-scope-guard --version` print the package version. Exit codes are `0` for a passing scan, `1` when the configured `--fail-on` threshold is met, and `2` for usage or scanner errors. + +The selected version applies to Admin GraphQL scope evidence. JSON, SARIF, and human output report its status, the latest stable version, current release candidate, and whether the evidence pack applies. Operations stay UNKNOWN outside the verified evidence version; an unlisted version is reported as unverified, not unsupported. diff --git a/docs/evidence-model.md b/docs/evidence-model.md index 8b91c5f..0028d93 100644 --- a/docs/evidence-model.md +++ b/docs/evidence-model.md @@ -1,7 +1,7 @@ # Evidence model -Evidence entries identify the API surface, operation, accepted scope alternatives, introduction/deprecation metadata, verification version, source URL, evidence type, and confidence. The bundled registry is offline and currently verified against Shopify API `2026-10`, the latest stable version as of October 1, 2026. +Evidence entries identify the API surface, operation, accepted scope alternatives, introduction/deprecation metadata, verification version, source URL, evidence type, and confidence. The bundled registry is offline and verified against Shopify API `2026-10`. Shopify's official [version schedule](https://shopify.dev/docs/api/usage/versioning), checked on 14 October 2026, lists `2026-10` as latest stable and `2027-01` as the release candidate. API-version status uses the documented `accessibleUntil` deadline where one is known; `2025-10` remains stable until 16 October 2026 at 15:00 UTC. The `--api-version` option and report metadata identify that status and whether the evidence pack applies. Selecting a release candidate does not apply stable-version mappings: Admin operations remain UNKNOWN until evidence is verified. Versions outside the status snapshot are labelled unverified rather than unsupported. The 2026-10 additions include statically identifiable GraphQL fields for billing fees, payment-instrument email, market hierarchy, analytics, reports, ShopifyQL, and root or discount-nested rollout connections. Resource-specific access needed by protected rollout payloads is intentionally not inferred. -The analyzer also maps the topic-level scopes printed on Shopify's 2026-10 Events topic references for the 18 bundled topics. It does this only when the app configuration sets `[events].api_version = "2026-10"`. Unknown topics and other Events versions remain UNKNOWN. This mapping does not infer trigger-specific scope requirements, or scopes used by a subscription's custom GraphQL `query`; query and `query_filter` contents are not reported. +The analyzer also maps the topic-level scopes printed on Shopify's 2026-10 Events topic references for the 18 bundled topics. It does this only when the app configuration sets `[events].api_version = "2026-10"`. Shopify announced Next Gen Events as generally available in 2026-10; the versioning page's note that Events remains unstable is stale relative to the [release announcement](https://shopify.dev/changelog/blog/next-generation-events-are-now-generally-available) and [subscription reference](https://shopify.dev/docs/apps/build/events/subscribe). Unknown topics and other Events versions remain UNKNOWN. This mapping does not infer trigger-specific scope requirements, or scopes used by a subscription's custom GraphQL `query`; query and `query_filter` contents are not reported. diff --git a/examples/03-redundant-read/app.graphql b/examples/03-redundant-read/app.ts similarity index 51% rename from examples/03-redundant-read/app.graphql rename to examples/03-redundant-read/app.ts index ebb3845..0920ca9 100644 --- a/examples/03-redundant-read/app.graphql +++ b/examples/03-redundant-read/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql query Products { products(first: 1) { nodes { id } } } +`); diff --git a/examples/04-optional-scope/app.graphql b/examples/04-optional-scope/app.ts similarity index 60% rename from examples/04-optional-scope/app.graphql rename to examples/04-optional-scope/app.ts index a61e783..76afc53 100644 --- a/examples/04-optional-scope/app.graphql +++ b/examples/04-optional-scope/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql mutation Create { productCreate(product: {title: "Demo"}) { product { id } } } +`); diff --git a/examples/05-write-implies-read/app.graphql b/examples/05-write-implies-read/app.ts similarity index 51% rename from examples/05-write-implies-read/app.graphql rename to examples/05-write-implies-read/app.ts index ebb3845..0920ca9 100644 --- a/examples/05-write-implies-read/app.graphql +++ b/examples/05-write-implies-read/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql query Products { products(first: 1) { nodes { id } } } +`); diff --git a/examples/06-orders/app.graphql b/examples/06-orders/app.ts similarity index 50% rename from examples/06-orders/app.graphql rename to examples/06-orders/app.ts index 7ece62c..5249178 100644 --- a/examples/06-orders/app.graphql +++ b/examples/06-orders/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql query Orders { orders(first: 10) { nodes { id } } } +`); diff --git a/examples/07-customers/app.graphql b/examples/07-customers/app.ts similarity index 53% rename from examples/07-customers/app.graphql rename to examples/07-customers/app.ts index f434207..c6e4ec7 100644 --- a/examples/07-customers/app.graphql +++ b/examples/07-customers/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql query Customers { customers(first: 10) { nodes { id } } } +`); diff --git a/examples/08-products/app.graphql b/examples/08-products/app.ts similarity index 65% rename from examples/08-products/app.graphql rename to examples/08-products/app.ts index 5408011..8afa463 100644 --- a/examples/08-products/app.graphql +++ b/examples/08-products/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql mutation Update { productUpdate(product: {id: "gid://shopify/Product/1"}) { product { id } } } +`); diff --git a/examples/09-inventory/app.graphql b/examples/09-inventory/app.ts similarity index 67% rename from examples/09-inventory/app.graphql rename to examples/09-inventory/app.ts index 683833f..cf44856 100644 --- a/examples/09-inventory/app.graphql +++ b/examples/09-inventory/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql query Inventory { inventoryItems(first: 10) { nodes { id inventoryLevels(first: 10) { nodes { id } } } } } +`); diff --git a/examples/10-metaobjects/app.graphql b/examples/10-metaobjects/app.ts similarity index 59% rename from examples/10-metaobjects/app.graphql rename to examples/10-metaobjects/app.ts index b0963e2..7f74eb1 100644 --- a/examples/10-metaobjects/app.graphql +++ b/examples/10-metaobjects/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql query Metaobjects { metaobjects(first: 10, type: "demo") { nodes { id } } } +`); diff --git a/examples/11-cart-transform/app.graphql b/examples/11-cart-transform/app.ts similarity index 63% rename from examples/11-cart-transform/app.graphql rename to examples/11-cart-transform/app.ts index c5a581b..c4797c1 100644 --- a/examples/11-cart-transform/app.graphql +++ b/examples/11-cart-transform/app.ts @@ -1 +1,3 @@ +const response = await admin.graphql(`#graphql mutation Transform { cartTransformCreate(functionId: "demo") { cartTransform { id } } } +`); diff --git a/examples/16-2026-10-evidence/app.graphql b/examples/16-2026-10-evidence/app.ts similarity index 88% rename from examples/16-2026-10-evidence/app.graphql rename to examples/16-2026-10-evidence/app.ts index 2b18f17..0dc0153 100644 --- a/examples/16-2026-10-evidence/app.graphql +++ b/examples/16-2026-10-evidence/app.ts @@ -1,3 +1,4 @@ +const response = await admin.graphql(`#graphql query Stable202610Evidence { shop { analyticsAnnotations(first: 1) { nodes { id } } @@ -10,3 +11,4 @@ mutation Stable202610Writes { analyticsAnnotationCreate(input: {title: "release"}) { analyticsAnnotation { id } } analyticsTargetCreate(input: {name: "release"}) { analyticsTarget { id } } } +`); diff --git a/src/action/index.js b/src/action/index.js index 057199f..674b88c 100644 --- a/src/action/index.js +++ b/src/action/index.js @@ -9,7 +9,7 @@ try { const format = input('format') || 'human', threshold = input('fail-on') || 'high'; validateOptions({ format, failOn: threshold }); if (input('show-unmapped') && !['true','false'].includes(input('show-unmapped'))) throw new Error('show-unmapped must be true or false'); - const report = audit({ root: input('path') || '.', configPath: input('config') || undefined }); + const report = audit({ root: input('path') || '.', configPath: input('config') || undefined, apiVersion: input('api-version') || undefined }); const rendered = format === 'json' ? toJson(report) : format === 'sarif' ? toSarif(report) : toHuman(report, { showUnmapped: input('show-unmapped') === 'true' }); const stop = randomUUID(); console.log(`::stop-commands::${stop}\n${rendered}\n::${stop}::`); const directory = process.env.RUNNER_TEMP || process.cwd(); @@ -18,7 +18,7 @@ try { if (format === 'sarif') fs.writeFileSync(path.join(directory, 'scope-guard.sarif'), rendered); const counts = severity => report.findings.filter(f => f.severity === severity).length; const failed = shouldFail(report, threshold); - const values = { outcome: failed ? 'failed' : 'passed', 'finding-count': report.findings.length, 'high-count': counts('high'), 'medium-count': counts('medium'), 'low-count': counts('low'), 'unknown-count': report.summary.unknown, 'missing-scope-count': report.findings.filter(f => ['SG-SCOPE-001','SG-SCOPE-002'].includes(f.ruleId)).length, 'redundant-scope-count': report.findings.filter(f => f.ruleId === 'SG-SCOPE-003').length, report: reportPath }; + const values = { outcome: failed ? 'failed' : 'passed', 'finding-count': report.findings.length, 'high-count': counts('high'), 'medium-count': counts('medium'), 'low-count': counts('low'), 'unknown-count': report.summary.unknown, 'missing-scope-count': report.findings.filter(f => ['SG-SCOPE-001','SG-SCOPE-002'].includes(f.ruleId)).length, 'redundant-scope-count': report.findings.filter(f => f.ruleId === 'SG-SCOPE-003').length, 'api-version': report.apiVersion.selected, 'api-version-status': report.apiVersion.status, 'evidence-version': report.apiVersion.evidenceVersion, 'evidence-applicable': report.apiVersion.evidenceApplicable, report: reportPath }; if (process.env.GITHUB_OUTPUT) for (const [key,value] of Object.entries(values)) { const delimiter = randomUUID(); fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}<<${delimiter}\n${value}\n${delimiter}\n`); } process.exitCode = failed ? 1 : 0; } catch (error) { console.error(`Scope Guard scanner error: ${String(error.message).replace(/[\r\n]/g, ' ')}`); process.exitCode = 2; } diff --git a/src/analyzer/index.js b/src/analyzer/index.js index 366e2a4..0b04154 100644 --- a/src/analyzer/index.js +++ b/src/analyzer/index.js @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { parseConfig, regularFile } from '../config/index.js'; import { extractGraphQL } from '../graphql/index.js'; -import { EVIDENCE_REGISTRY, EVIDENCE_VERSION, EVIDENCE_SOURCES, EVENTS_TOPIC_EVIDENCE, IMPLIED_SCOPES, rulesFor } from '../evidence/registry.js'; +import { EVIDENCE_REGISTRY, EVIDENCE_VERSION, EVIDENCE_SOURCES, EVENTS_TOPIC_EVIDENCE, IMPLIED_SCOPES, API_VERSION_INTELLIGENCE, rulesFor } from '../evidence/registry.js'; const DEFAULT_IGNORES = new Set(['.git', 'node_modules', 'vendor', 'dist', 'build', 'coverage', '.cache', 'tmp', 'fixtures']); const CODE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.graphql', '.gql']); @@ -26,32 +26,80 @@ function finding(ruleId, severity, confidence, scope, file, line, explanation, e return { ruleId, severity, confidence, scope, file: file ?? null, line: line ?? null, explanation, evidence, source, remediation }; } -export function audit({ root = '.', configPath, include, exclude = [] } = {}) { - const absoluteRoot = path.resolve(root); +function discoverAppConfigs(root, current = root, result = []) { + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith('.') || DEFAULT_IGNORES.has(entry.name)) continue; + const full = path.join(current, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) discoverAppConfigs(root, full, result); + else if (entry.isFile() && /^shopify\.app(?:\..+)?\.toml$/.test(entry.name)) result.push(full); + } + return result; +} + +function operationSurface(operation) { + const context = operation.sourceContext ?? ''; + const evidence = []; + if (/\badmin\s*\.\s*graphql\s*\(|\bauthenticate\s*\.\s*admin\b|\/admin\/api\/\d{4}-\d{2}\/graphql\.json|\bcurrentAppInstallation\b/i.test(context)) evidence.push('admin-graphql'); + if (/\bstorefront\s*\.\s*graphql\s*\(|\bunauthenticated\s*\.\s*storefront\b|storefront-access-token|https?:\/\/[^/\s]+\/api\/\d{4}-\d{2}\/graphql\.json/i.test(context)) evidence.push('storefront-graphql'); + if (/\bcustomerAccount\s*\.\s*graphql\s*\(|\bunauthenticated\s*\.\s*customerAccount\b|\/customer\/api\//i.test(context)) evidence.push('customer-account-graphql'); + return evidence.length === 1 ? evidence[0] : null; +} + +function apiStatus(version, now = new Date()) { + const status = API_VERSION_INTELLIGENCE.statuses[version]; + if (status === 'stable') { + const accessibleUntil = API_VERSION_INTELLIGENCE.accessibleUntil[version]; + if (accessibleUntil && now.getTime() >= Date.parse(accessibleUntil)) return 'unsupported'; + } + return status ?? 'unverified'; +} + +export function audit({ root = '.', configPath, include, exclude = [], apiVersion = API_VERSION_INTELLIGENCE.latestStable, now = new Date() } = {}) { + if (typeof apiVersion !== 'string' || !/^(?:\d{4}-\d{2}|unstable)$/.test(apiVersion)) throw new Error('API version must use YYYY-MM or unstable'); + let absoluteRoot = path.resolve(root); if (!fs.lstatSync(absoluteRoot).isDirectory()) throw new Error('Scan root must be a regular directory'); - const candidates = fs.readdirSync(absoluteRoot).filter(f => /^shopify\.app(?:\..+)?\.toml$/.test(f)).sort(); - if (!configPath && candidates.length > 1 && !candidates.includes('shopify.app.toml')) throw new Error('Multiple app configurations found; select one with --config'); - const selected = configPath ?? (candidates.includes('shopify.app.toml') ? 'shopify.app.toml' : candidates[0]); - if (!selected) throw new Error('Shopify app configuration not found; select one with --config'); - const configFile = path.resolve(absoluteRoot, selected); - regularFile(absoluteRoot, configFile); + let configFile; + if (configPath) { + configFile = path.resolve(absoluteRoot, configPath); + regularFile(absoluteRoot, configFile); + absoluteRoot = path.dirname(configFile); + if (discoverAppConfigs(absoluteRoot).some(file => path.dirname(file) !== absoluteRoot)) throw new Error('Nested Shopify app roots found; select one with --path or --config'); + } else { + const candidates = fs.readdirSync(absoluteRoot).filter(f => /^shopify\.app(?:\..+)?\.toml$/.test(f)).sort(); + const nestedRoots = [...new Set(discoverAppConfigs(absoluteRoot).map(file => path.dirname(file)).filter(dir => dir !== absoluteRoot))]; + if (nestedRoots.length) throw new Error('Nested Shopify app roots found; select one with --path or --config'); + if (candidates.length > 1 && !candidates.includes('shopify.app.toml')) throw new Error('Multiple app configurations found; select one with --config'); + const selected = candidates.includes('shopify.app.toml') ? 'shopify.app.toml' : candidates[0]; + if (!selected) throw new Error('Shopify app configuration not found; select one with --config'); + configFile = path.resolve(absoluteRoot, selected); + regularFile(absoluteRoot, configFile); + } const config = parseConfig(configFile); const skipped = []; const files = walk(absoluteRoot, skipped).filter(file => !exclude.some(value => file.includes(value)) && (!include || file.includes(include))); const observations = [], findings = [], unknown = []; for (const file of files) { const text = fs.readFileSync(file, 'utf8'); - const surface = /\/admin\/api\/[^/]+\/graphql\.json|admin\.graphql|authenticate\.admin|currentAppInstallation/.test(text) ? 'admin-graphql' : (/storefront|unauthenticated_read_/.test(text) ? 'storefront' : /\.(graphql|gql)$/i.test(file) ? 'admin-graphql' : null); - if (!surface && /graphql|shopify/i.test(text)) { unknown.push({ file: path.relative(absoluteRoot, file), reason: 'Shopify-related code could not be assigned to a supported API surface.' }); continue; } - if (surface === 'storefront') continue; const operations = extractGraphQL(text, file); - if (surface === 'admin-graphql' && operations.length === 0 && /(admin\.graphql|client\.(query|request)|graphql\s*\()/.test(text)) { unknown.push({ file: path.relative(absoluteRoot, file), line: 1, reason: 'Shopify Admin GraphQL client usage was found, but the query was not a static document.' }); } + if (operations.length === 0 && /(?:admin|storefront|customerAccount)\.graphql|\/admin\/api\/|\/customer\/api\//i.test(text)) { unknown.push({ file: path.relative(absoluteRoot, file), line: 1, reason: 'Shopify API client usage was found, but the query was not a static document.' }); } for (const op of operations) { if (op.parseError) { unknown.push({ file: path.relative(absoluteRoot, file), line: op.line, reason: 'GraphQL-like text could not be parsed safely.' }); continue; } + const surface = operationSurface(op); + if (!surface) { unknown.push({ file: path.relative(absoluteRoot, file), line: op.line, reason: 'GraphQL operation could not be assigned to one supported API surface.' }); continue; } + if (surface === 'storefront-graphql') continue; + if (surface === 'customer-account-graphql') { + unknown.push({ file: path.relative(absoluteRoot, file), line: op.line, reason: 'Customer Account GraphQL is identifiable but is outside the Admin GraphQL scope evidence pack.' }); + continue; + } + if (apiVersion !== EVIDENCE_VERSION) { + unknown.push({ file: path.relative(absoluteRoot, file), line: op.line, reason: `Admin GraphQL scope evidence is verified for ${EVIDENCE_VERSION} only; selected API version ${apiVersion} is ${apiStatus(apiVersion, now)}.` }); + continue; + } for (const field of op.fields) { const rules = rulesFor(field.path, op.type); if (!rules.length) { if (!field.path.includes('.') && !['shop', '__typename'].includes(field.path)) unknown.push({ file: path.relative(absoluteRoot, file), line: field.line, reason: 'GraphQL root operation is outside the bundled scope registry.' }); continue; } - for (const item of rules) observations.push({ ...item, operationType: op.type, file, line: field.line, operationName: op.name }); + for (const item of rules) observations.push({ ...item, apiSurface: surface, operationType: op.type, file, line: field.line, operationName: op.name }); } } } @@ -86,5 +134,21 @@ export function audit({ root = '.', configPath, include, exclude = [] } = {}) { for (const scope of config.optional) if (!observedScopes.has(scope)) findings.push(finding('SG-SCOPE-004', 'low', 'high', scope, path.relative(absoluteRoot, configFile).replaceAll(path.sep, '/'), null, `No supported usage requiring optional scope ${scope} was evidenced.`, 'No matching supported operation found', EVIDENCE_SOURCES.scopes, `Review the optional feature path; this result does not prove the scope is unused.`)); if (unknown.length) findings.push(finding('SG-SCOPE-006', 'info', 'low', null, unknown[0].file, unknown[0].line, 'Shopify-related code was found but could not be safely mapped to supported static evidence.', unknown.map(item => item.reason).join('; '), EVIDENCE_SOURCES.scopes, 'Review the unmapped code manually.')); findings.sort((a, b) => `${a.severity}:${a.ruleId}:${a.file}:${a.line}`.localeCompare(`${b.severity}:${b.ruleId}:${b.file}:${b.line}`)); - return { tool: { name: 'shopify-scope-guard', version: TOOL_VERSION }, evidence: { api: 'admin-graphql', version: EVIDENCE_VERSION, source: EVIDENCE_SOURCES.versioning }, summary: { declaredRequired: config.required.length, declaredOptional: config.optional.length, evidenced: observedScopes.size, unknown: unknown.length, findingCount: findings.length }, scopes: { required: config.required, optional: config.optional }, observations: observations.map(o => ({ operation: o.operation, scope: o.requires.anyOf, file: path.relative(absoluteRoot, o.file).replaceAll(path.sep, '/'), line: o.line })), findings, unknown, skipped }; + return { + tool: { name: 'shopify-scope-guard', version: TOOL_VERSION }, + evidence: { api: 'admin-graphql', version: EVIDENCE_VERSION, source: EVIDENCE_SOURCES.versioning }, + apiVersion: { + selected: apiVersion, + status: apiStatus(apiVersion, now), + latestStable: API_VERSION_INTELLIGENCE.latestStable, + releaseCandidate: API_VERSION_INTELLIGENCE.releaseCandidate, + evidenceVersion: EVIDENCE_VERSION, + evidenceApplicable: apiVersion === EVIDENCE_VERSION, + source: API_VERSION_INTELLIGENCE.source, + }, + summary: { declaredRequired: config.required.length, declaredOptional: config.optional.length, evidenced: observedScopes.size, unknown: unknown.length, findingCount: findings.length }, + scopes: { required: config.required, optional: config.optional }, + observations: observations.map(o => ({ operation: o.operation, apiSurface: o.apiSurface ?? 'events', scope: o.requires.anyOf, file: path.relative(absoluteRoot, o.file).replaceAll(path.sep, '/'), line: o.line })), + findings, unknown, skipped, + }; } diff --git a/src/cli/index.js b/src/cli/index.js index cbb65b4..69f9d5a 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -2,22 +2,22 @@ import { TOOL_VERSION } from '../version.js'; import { audit } from '../analyzer/index.js'; import { toHuman, toJson, toSarif } from '../output/index.js'; -import { EVIDENCE_REGISTRY, EVIDENCE_VERSION } from '../evidence/registry.js'; +import { EVIDENCE_REGISTRY, EVIDENCE_VERSION, API_VERSION_INTELLIGENCE } from '../evidence/registry.js'; import { shouldFail, validateOptions } from '../policy.js'; const args = process.argv.slice(2); -const help = 'Usage: shopify-scope-guard audit [path] [--path DIR] [--config FILE] [--include TEXT] [--format human|json|sarif] [--fail-on none|low|medium|high] [--show-unmapped]\n shopify-scope-guard rules|explain RULE|--version'; +const help = 'Usage: shopify-scope-guard audit [path] [--path DIR] [--config FILE] [--api-version YYYY-MM] [--include TEXT] [--format human|json|sarif] [--fail-on none|low|medium|high] [--show-unmapped]\n shopify-scope-guard rules|explain RULE|--version'; if (args.includes('--help') || args.includes('-h')) { console.log(help); process.exit(0); } if (args.includes('--version') || args[0] === 'version') { console.log(TOOL_VERSION); process.exit(0); } const command = args[0] && !args[0].startsWith('-') ? args.shift() : 'audit'; try { - if (command === 'rules') { console.log(JSON.stringify({ evidenceVersion: EVIDENCE_VERSION, rules: EVIDENCE_REGISTRY }, null, 2)); process.exit(0); } + if (command === 'rules') { console.log(JSON.stringify({ evidenceVersion: EVIDENCE_VERSION, apiVersions: API_VERSION_INTELLIGENCE, rules: EVIDENCE_REGISTRY }, null, 2)); process.exit(0); } if (command === 'explain') { const rules = EVIDENCE_REGISTRY.filter(r => r.ruleId === args[0]); if (!rules.length) throw new Error('Unknown rule ID'); console.log(JSON.stringify(rules, null, 2)); process.exit(0); } if (command !== 'audit') throw new Error(help); const options = {}; let positional; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--show-unmapped') { options.showUnmapped = true; continue; } - if (['--path', '--config', '--include', '--format', '--fail-on'].includes(arg)) { + if (['--path', '--config', '--api-version', '--include', '--format', '--fail-on'].includes(arg)) { if (!args[i + 1] || args[i + 1].startsWith('-')) throw new Error(`Missing value for ${arg}`); options[arg.slice(2)] = args[++i]; } else if (!arg.startsWith('-') && positional === undefined) positional = arg; @@ -25,7 +25,7 @@ try { } const format = options.format ?? 'human', threshold = options['fail-on'] ?? 'none'; validateOptions({ format, failOn: threshold }); - const report = audit({ root: options.path ?? positional ?? '.', configPath: options.config, include: options.include }); + const report = audit({ root: options.path ?? positional ?? '.', configPath: options.config, include: options.include, apiVersion: options['api-version'] ?? API_VERSION_INTELLIGENCE.latestStable }); console.log(format === 'json' ? toJson(report) : format === 'sarif' ? toSarif(report) : toHuman(report, { showUnmapped: options.showUnmapped })); process.exitCode = shouldFail(report, threshold) ? 1 : 0; } catch (error) { console.error(`Scanner error: ${error.message}`); process.exitCode = 2; } diff --git a/src/evidence/registry.js b/src/evidence/registry.js index f6e6f47..76dd8ed 100644 --- a/src/evidence/registry.js +++ b/src/evidence/registry.js @@ -1,4 +1,22 @@ export const EVIDENCE_VERSION = '2026-10'; +export const API_VERSION_INTELLIGENCE = Object.freeze({ + latestStable: '2026-10', + releaseCandidate: '2027-01', + verifiedAt: '2026-10-14', + statuses: Object.freeze({ + '2025-04': 'unsupported', + '2025-07': 'unsupported', + '2025-10': 'stable', + '2026-01': 'stable', + '2026-04': 'stable', + '2026-07': 'stable', + '2026-10': 'stable', + '2027-01': 'release-candidate', + unstable: 'unstable', + }), + accessibleUntil: Object.freeze({ '2025-10': '2026-10-16T15:00:00Z' }), + source: 'https://shopify.dev/docs/api/usage/versioning', +}); export const EVIDENCE_SOURCES = { scopes: 'https://shopify.dev/docs/apps/build/authentication-authorization/manage-access-scopes', accessScopes: 'https://shopify.dev/docs/api/usage/access-scopes', diff --git a/src/graphql/index.js b/src/graphql/index.js index 5853087..26300a3 100644 --- a/src/graphql/index.js +++ b/src/graphql/index.js @@ -17,22 +17,66 @@ export function parseGraphQL(source, file, lineOffset = 0) { }); return document.definitions.filter(d => d.kind === Kind.OPERATION_DEFINITION).map(node => ({ type: node.operation, name: node.name?.value ?? null, fields: collectFields(node.selectionSet), file, line: (node.loc?.startToken?.line ?? 1) + lineOffset })); } + +function maskComments(source) { + let output = '', quote = '', escaped = false; + for (let index = 0; index < source.length; index++) { + const character = source[index], next = source[index + 1]; + if (quote) { + output += character; + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + continue; + } + if (character === '"' || character === "'" || character === String.fromCharCode(96)) { quote = character; output += character; continue; } + if (character === '/' && next === '/') { + output += ' '; index++; + while (index + 1 < source.length && source[index + 1] !== '\n') { index++; output += ' '; } + continue; + } + if (character === '/' && next === '*') { + output += ' '; index++; + while (index + 1 < source.length && !(source[index] === '*' && source[index + 1] === '/')) { index++; output += source[index] === '\n' ? '\n' : ' '; } + if (index + 1 < source.length) { index++; output += ' '; } + continue; + } + output += character; + } + return output; +} + export function extractGraphQL(text, file) { - const documents = new Map(); - if (/\.(graphql|gql)$/i.test(file)) documents.set(0, text); + const documents = []; + if (/\.(graphql|gql)$/i.test(file)) documents.push({ offset: 0, source: text, end: text.length }); else { const patterns = [ /#graphql\s*([\s\S]*?)(?=`)/g, /(?:graphql|gql)\s*(?:\(\s*)?`([\s\S]*?)`/g, /query\s*[:=]\s*`([\s\S]*?)`/g ]; for (const pattern of patterns) for (const match of text.matchAll(pattern)) { - const offset = match.index + match[0].indexOf(match[1]); - // A #graphql template may also match the call pattern; retain one document. - const source = match[1].replace(/^#graphql\s*/, ''); - if (![...documents.values()].some(d => d.replace(/^#graphql\s*/, '') === source)) documents.set(offset, match[1]); + const raw = match[1], rawOffset = match.index + match[0].indexOf(raw); + const prefix = raw.match(/^#graphql\s*/)?.[0] ?? ''; + const source = raw.slice(prefix.length), offset = rawOffset + prefix.length; + if (!documents.some(document => document.source === source)) documents.push({ offset, source, end: offset + source.length }); } } const result = []; - for (const [offset, source] of documents) { - try { result.push(...parseGraphQL(source, file, text.slice(0, offset).split('\n').length - 1)); } - catch { result.push({ type: 'unknown', name: null, fields: [], file, line: text.slice(0, offset).split('\n').length, parseError: true }); } + for (const document of documents) { + const lineOffset = text.slice(0, document.offset).split('\n').length - 1; + let sourceContext = ''; + if (!/\.(graphql|gql)$/i.test(file)) { + const templateStart = text.lastIndexOf('`', document.offset); + const templateEnd = text.indexOf('`', document.end); + if (templateStart >= 0 && templateEnd >= 0) { + const statementStart = Math.max(text.lastIndexOf(';', templateStart) + 1, templateStart - 180); + const before = text.slice(statementStart, templateStart); + sourceContext = maskComments(before); + } + } + try { + result.push(...parseGraphQL(document.source, file, lineOffset).map(operation => ({ + ...operation, + sourceContext, + }))); + } catch { result.push({ type: 'unknown', name: null, fields: [], file, line: text.slice(0, document.offset).split('\n').length, parseError: true, sourceContext: '' }); } } return result; } diff --git a/src/output/index.js b/src/output/index.js index 66c3783..054e230 100644 --- a/src/output/index.js +++ b/src/output/index.js @@ -1,10 +1,12 @@ const rank = { info: 0, low: 1, medium: 2, high: 3 }; export function toJson(report) { return JSON.stringify(report, null, 2) + '\n'; } export function toHuman(report, { showUnmapped = false } = {}) { - const lines = ['Shopify Scope Guard', '', 'Scope audit', '────────────', `Declared: required ${report.summary.declaredRequired}, optional ${report.summary.declaredOptional}`, `Observed: evidenced ${report.summary.evidenced}, unknown ${report.summary.unknown}`, `Findings: ${report.summary.findingCount}`, '']; + const lines = ['Shopify Scope Guard', '', 'Scope audit', '────────────']; + if (report.apiVersion) lines.push(`Admin GraphQL target: ${report.apiVersion.selected} (${report.apiVersion.status}); bundled evidence: ${report.apiVersion.evidenceVersion} (${report.apiVersion.evidenceApplicable ? 'applicable' : 'not applicable'})`); + lines.push(`Declared: required ${report.summary.declaredRequired}, optional ${report.summary.declaredOptional}`, `Observed: evidenced ${report.summary.evidenced}, unknown ${report.summary.unknown}`, `Findings: ${report.summary.findingCount}`, ''); for (const f of [...report.findings].sort((a,b) => rank[b.severity]-rank[a.severity])) { lines.push(`${f.severity.toUpperCase()} ${f.ruleId}${f.scope ? ` — ${f.scope}` : ''}`, ` ${f.explanation}`, ` Evidence: ${f.evidence}`, ` Source: ${f.source}`, ` Location: ${f.file ?? 'repository'}${f.line ? `:${f.line}` : ''}`, ` Recommendation: ${f.remediation}`, ''); } if (showUnmapped) for (const item of report.unknown) lines.push(`UNKNOWN ${item.file}${item.line ? ':'+item.line : ''}: ${item.reason}`); if (report.skipped?.length) lines.push(`Skipped analysis: ${report.skipped.length} file(s); inspect JSON and review manually.`); return lines.join('\n'); } -export function toSarif(report) { return JSON.stringify({ version: '2.1.0', $schema: 'https://json.schemastore.org/sarif-2.1.0.json', runs: [{ tool: { driver: { name: report.tool.name, version: report.tool.version, informationUri: 'https://github.com/RexCode-Digital/shopify-scope-guard', rules: [...new Map(report.findings.map(f => [f.ruleId, { id: f.ruleId, shortDescription: { text: f.explanation }, helpUri: f.source }])).values()] } }, results: report.findings.map(f => ({ ruleId: f.ruleId, level: f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'note', message: { text: f.explanation }, locations: f.file ? [{ physicalLocation: { artifactLocation: { uri: f.file.split('/').map(encodeURIComponent).join('/') }, region: f.line ? { startLine: f.line } : undefined } }] : undefined })) }] }, null, 2) + '\n'; } +export function toSarif(report) { return JSON.stringify({ version: '2.1.0', $schema: 'https://json.schemastore.org/sarif-2.1.0.json', runs: [{ tool: { driver: { name: report.tool.name, version: report.tool.version, informationUri: 'https://github.com/RexCode-Digital/shopify-scope-guard', rules: [...new Map(report.findings.map(f => [f.ruleId, { id: f.ruleId, shortDescription: { text: f.explanation }, helpUri: f.source }])).values()] } }, properties: { apiVersion: report.apiVersion }, results: report.findings.map(f => ({ ruleId: f.ruleId, level: f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'note', message: { text: f.explanation }, locations: f.file ? [{ physicalLocation: { artifactLocation: { uri: f.file.split('/').map(encodeURIComponent).join('/') }, region: f.line ? { startLine: f.line } : undefined } }] : undefined })) }] }, null, 2) + '\n'; } diff --git a/test/analyzer.test.js b/test/analyzer.test.js index 0ac0b25..1269d24 100644 --- a/test/analyzer.test.js +++ b/test/analyzer.test.js @@ -31,14 +31,16 @@ test('discount rollouts nested under discountNode require read_rollouts', t => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-discount-rollouts-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); fs.writeFileSync(path.join(root, 'shopify.app.toml'), '[access_scopes]\nscopes="read_discounts"'); - fs.writeFileSync(path.join(root, 'discount.graphql'), `query DiscountRollouts { + fs.writeFileSync(path.join(root, 'discount.ts'), `const response = await admin.graphql(\`#graphql +query DiscountRollouts { discountNode(id: "gid://shopify/DiscountNode/1") { discount { ...RolloutFields } } } fragment RolloutFields on DiscountCodeBasic { rolloutInfo: rollouts(first: 1) { nodes { id } } - }`); + } +\`);`); const report = audit({ root }); assert.ok(report.observations.some(item => item.operation === 'discountNode.discount.rollouts' && item.scope.includes('read_rollouts'))); assert.ok(report.findings.some(item => item.ruleId === 'SG-SCOPE-001' && item.scope === 'read_rollouts')); @@ -59,7 +61,7 @@ for (const rule of EVIDENCE_REGISTRY) { const parts=rule.operation.split('.'); let selection = parts.pop() + '{ id }'; for(const part of parts.reverse()) selection = `${part} { ${selection} }`; - fs.writeFileSync(path.join(root,'operation.graphql'),`${rule.operationType} { ${selection} }`); + fs.writeFileSync(path.join(root,'operation.ts'),`const response = await admin.graphql(\`#graphql\n${rule.operationType} { ${selection} }\n\`);`); const report=audit({root}); assert.ok(report.observations.some(o=>o.operation===rule.operation)); assert.ok(report.findings.some(f=>f.scope===rule.requires.anyOf[0]&&f.ruleId==='SG-SCOPE-001')); diff --git a/test/current-evidence.test.js b/test/current-evidence.test.js index c39ed22..f356ad0 100644 --- a/test/current-evidence.test.js +++ b/test/current-evidence.test.js @@ -18,7 +18,7 @@ function root(t, app, files = {}) { test('2026-10 payment, billing, and market hierarchy evidence is mapped to official scopes', t => { const dir = root(t, '[access_scopes]\nscopes=""', { - 'api.graphql': `mutation AddPayment { paymentInstrumentSendAddEmail(mandate: { resourceType: ORDERS, resourceId: "1" }) { customer { id } } }\nquery BillingAndMarkets { feeDetails { id } marketRelationshipsStatus { version } marketRelationships(first: 1) { nodes { parentMarket { id } childMarket { id } } } market(id: "gid://shopify/Market/1") { parentMarkets(first: 1) { nodes { id } } childMarketsCount } }` + 'api.ts': `const response = await admin.graphql(\`#graphql\nmutation AddPayment { paymentInstrumentSendAddEmail(mandate: { resourceType: ORDERS, resourceId: "1" }) { customer { id } } }\nquery BillingAndMarkets { feeDetails { id } marketRelationshipsStatus { version } marketRelationships(first: 1) { nodes { parentMarket { id } childMarket { id } } } market(id: "gid://shopify/Market/1") { parentMarkets(first: 1) { nodes { id } } childMarketsCount } }\n\`);` }); const report = audit({ root: dir }); for (const [operation, scope] of [['paymentInstrumentSendAddEmail', 'write_customers'], ['feeDetails', 'read_billing'], ['marketRelationships', 'read_markets'], ['marketRelationshipsStatus', 'read_markets'], ['marketRelationships.nodes.parentMarket', 'read_markets'], ['marketRelationships.nodes.childMarket', 'read_markets'], ['market.parentMarkets', 'read_markets'], ['market.childMarketsCount', 'read_markets']]) { @@ -46,3 +46,23 @@ test('unknown Events topics and non-2026-10 API versions remain unknown', t => { assert.ok(!report.observations.some(item => item.operation.startsWith('events.subscription.'))); } }); + +test('API version intelligence distinguishes current stable from release candidate', t => { + const dir = root(t, '[access_scopes]\nscopes=""'); + const report = audit({ root: dir }); + assert.deepEqual( + { selected: report.apiVersion.selected, status: report.apiVersion.status, applicable: report.apiVersion.evidenceApplicable }, + { selected: '2026-10', status: 'stable', applicable: true }, + ); + const rc = audit({ root: dir, apiVersion: '2027-01' }); + assert.equal(rc.apiVersion.status, 'release-candidate'); + assert.equal(rc.apiVersion.evidenceApplicable, false); +}); + +test('2025-10 remains supported until Shopify accessibility deadline', t => { + const dir = root(t, '[access_scopes]\nscopes=""'); + const beforeDeadline = audit({ root: dir, apiVersion: '2025-10', now: new Date('2026-10-16T14:59:59Z') }); + assert.equal(beforeDeadline.apiVersion.status, 'stable'); + const atDeadline = audit({ root: dir, apiVersion: '2025-10', now: new Date('2026-10-16T15:00:00Z') }); + assert.equal(atDeadline.apiVersion.status, 'unsupported'); +}); diff --git a/test/hardening.test.js b/test/hardening.test.js index 9f9fe76..ac8ba9b 100644 --- a/test/hardening.test.js +++ b/test/hardening.test.js @@ -8,18 +8,68 @@ import { audit } from '../src/analyzer/index.js'; import { parseGraphQL } from '../src/graphql/index.js'; const cli=path.resolve('src/cli/index.js'),action=path.resolve('dist/index.cjs'); function fixture(t,scopes=''){const root=fs.mkdtempSync(path.join(os.tmpdir(),'scope-hardening-'));t.after(()=>fs.rmSync(root,{recursive:true,force:true}));fs.writeFileSync(path.join(root,'shopify.app.toml'),`[access_scopes]\nscopes="${scopes}"\n`);return root;} +const adminDocument = query => `const response = await admin.graphql(\`#graphql\n${query}\n\`);`; test('local fragments, aliases and inline fragments preserve field paths',()=>{ const fields=parseGraphQL('query { ...Root } fragment Root on QueryRoot { alias: products(first:1){... on ProductConnection {nodes {id}}} }','app.graphql')[0].fields;assert.ok(fields.some(f=>f.path==='products'));assert.ok(fields.some(f=>f.path==='products.nodes.id')); assert.throws(()=>parseGraphQL('query {...Missing}','x.graphql'),/Unresolved/);assert.throws(()=>parseGraphQL('query {...A} fragment A on QueryRoot {...A}','x.graphql'),/cyclic/); }); test('fragments produce missing scopes and unresolved fragments stay unknown',t=>{ - const root=fixture(t);fs.writeFileSync(path.join(root,'app.graphql'),'query {...Root}\nfragment Root on QueryRoot {products(first:1){nodes{id}}}');let r=audit({root});assert.equal(r.findings[0].scope,'read_products');assert.equal(r.findings[0].file,'app.graphql');assert.equal(r.findings[0].line,2); - fs.writeFileSync(path.join(root,'app.graphql'),'query {...Missing}');r=audit({root});assert.ok(r.unknown.length); + const root=fixture(t);fs.writeFileSync(path.join(root,'app.ts'),adminDocument('query {...Root}\nfragment Root on QueryRoot {products(first:1){nodes{id}}}'));let r=audit({root});assert.equal(r.findings[0].scope,'read_products');assert.equal(r.findings[0].file,'app.ts');assert.equal(r.findings[0].line,3); + fs.writeFileSync(path.join(root,'app.ts'),adminDocument('query {...Missing}'));r=audit({root});assert.ok(r.unknown.length); }); test('unsupported roots and explicit Storefront documents do not become Admin evidence',t=>{ const root=fixture(t);fs.writeFileSync(path.join(root,'app.graphql'),'# storefront\nquery {products(first:1){nodes{id}}}');assert.equal(audit({root}).observations.length,0); fs.writeFileSync(path.join(root,'app.graphql'),'query {notMapped{id}}');assert.equal(audit({root}).unknown.length,1); }); +test('classifies independent API surfaces per GraphQL document',t=>{ + const root=fixture(t),tick=String.fromCharCode(96); + fs.writeFileSync(path.join(root,'mixed.ts'),[ + 'const adminResult = await admin.graphql('+tick+'#graphql', + 'query AdminProducts { products(first: 1) { nodes { id } } }', + tick+');', + 'const storefrontResult = await storefront.graphql('+tick+'#graphql', + 'query StorefrontProducts { products(first: 1) { nodes { id } } }', + tick+');', + ].join('\n')); + const report=audit({root}); + assert.equal(report.observations.length,1); + assert.equal(report.observations[0].apiSurface,'admin-graphql'); + assert.ok(report.findings.some(f=>f.scope==='read_products')); + assert.equal(report.unknown.length,0); +}); +test('Customer Account and ambiguous operations stay unknown',t=>{ + const root=fixture(t),tick=String.fromCharCode(96); + fs.writeFileSync(path.join(root,'account.ts'),'const response = await customerAccount.graphql('+tick+'#graphql\nquery Account { customer { id } }\n'+tick+');'); + fs.writeFileSync(path.join(root,'ambiguous.ts'),'const query = '+tick+'#graphql\nquery Product { products(first: 1) { nodes { id } } }\n'+tick+';'); + const report=audit({root}); + assert.ok(report.unknown.some(item=>item.reason.includes('Customer Account GraphQL'))); + assert.ok(report.unknown.some(item=>item.reason.includes('assigned to one supported API surface'))); + assert.equal(report.observations.length,0); +}); +test('release-candidate version metadata is explicit and unverified operations remain unknown',t=>{ + const root=fixture(t); + fs.writeFileSync(path.join(root,'admin.ts'),adminDocument('query Products { products(first: 1) { nodes { id } } }')); + const report=audit({root,apiVersion:'2027-01'}); + assert.equal(report.apiVersion.selected,'2027-01'); + assert.equal(report.apiVersion.status,'release-candidate'); + assert.equal(report.apiVersion.latestStable,'2026-10'); + assert.equal(report.apiVersion.evidenceVersion,'2026-10'); + assert.equal(report.apiVersion.evidenceApplicable,false); + assert.equal(report.observations.length,0); + assert.ok(report.unknown.some(item=>item.reason.includes('release-candidate'))); +}); +test('selecting one nested app restricts code scanning to its configuration root',t=>{ + const root=fs.mkdtempSync(path.join(os.tmpdir(),'scope-monorepo-'));t.after(()=>fs.rmSync(root,{recursive:true,force:true})); + const appA=path.join(root,'apps','a'),appB=path.join(root,'apps','b'); + fs.mkdirSync(appA,{recursive:true});fs.mkdirSync(appB,{recursive:true}); + fs.writeFileSync(path.join(appA,'shopify.app.toml'),'[access_scopes]\nscopes=""'); + fs.writeFileSync(path.join(appB,'shopify.app.toml'),'[access_scopes]\nscopes=""'); + fs.writeFileSync(path.join(appB,'admin.ts'),adminDocument('query Products { products(first:1) { nodes { id } } }')); + assert.throws(()=>audit({root}),/Nested Shopify app roots/); + const report=audit({root,configPath:'apps/a/shopify.app.toml'}); + assert.equal(report.observations.length,0); + assert.equal(report.findings.length,0); +}); test('relative configuration is resolved inside root and ambiguity is rejected',t=>{ const root=fixture(t);fs.renameSync(path.join(root,'shopify.app.toml'),path.join(root,'shopify.app.dev.toml'));fs.writeFileSync(path.join(root,'shopify.app.prod.toml'),'[access_scopes]\nscopes=""');assert.throws(()=>audit({root}),/Multiple/);assert.equal(audit({root,configPath:'shopify.app.dev.toml'}).tool.version,'0.2.4');assert.throws(()=>audit({root,configPath:'../external.toml'}),/inside/); }); @@ -30,7 +80,8 @@ test('symlinks and oversized files are skipped; malformed config is redacted',t= fs.writeFileSync(path.join(root,'shopify.app.toml'),'secret="hidden"\ninvalid=[');assert.throws(()=>audit({root}),/Invalid Shopify app TOML/); }); test('CLI policies reject typo and missing values',t=>{ - const root=fixture(t);for(const args of [['--fail-on','typo'],['--format','typo'],['--config'],['--unknown']]) assert.equal(spawnSync(process.execPath,[cli,'audit',root,...args]).status,2);assert.equal(spawnSync(process.execPath,[cli,'--help']).status,0); + const root=fixture(t);for(const args of [['--fail-on','typo'],['--format','typo'],['--config'],['--api-version'],['--api-version','unstable-now'],['--unknown']]) assert.equal(spawnSync(process.execPath,[cli,'audit',root,...args]).status,2);assert.equal(spawnSync(process.execPath,[cli,'--help']).status,0); + const rc=spawnSync(process.execPath,[cli,'audit',root,'--format','json','--api-version','2027-01'],{encoding:'utf8'});assert.equal(rc.status,0,rc.stderr);assert.equal(JSON.parse(rc.stdout).apiVersion.status,'release-candidate'); }); test('Action outcome agrees with none and medium policies and returns JSON file',t=>{ const root=fixture(t,'read_products,write_products'),out=path.join(root,'outputs');fs.writeFileSync(out,''); @@ -39,9 +90,9 @@ test('Action outcome agrees with none and medium policies and returns JSON file' }); test('read analytics scopes cannot authorize write mutations',t=>{ - const root=fixture(t,'read_analytics_annotations,read_reports');fs.writeFileSync(path.join(root,'writes.graphql'),'mutation { analyticsAnnotationCreate(input:{title:"x"}){analyticsAnnotation{id}} analyticsTargetCreate(input:{name:"x"}){analyticsTarget{id}} }');const r=audit({root});assert.ok(r.findings.some(f=>f.scope==='write_analytics_annotations'&&f.ruleId==='SG-SCOPE-001'));assert.ok(r.findings.some(f=>f.scope==='write_reports'&&f.ruleId==='SG-SCOPE-001')); + const root=fixture(t,'read_analytics_annotations,read_reports');fs.writeFileSync(path.join(root,'writes.ts'),adminDocument('mutation { analyticsAnnotationCreate(input:{title:"x"}){analyticsAnnotation{id}} analyticsTargetCreate(input:{name:"x"}){analyticsTarget{id}} }'));const r=audit({root});assert.ok(r.findings.some(f=>f.scope==='write_analytics_annotations'&&f.ruleId==='SG-SCOPE-001'));assert.ok(r.findings.some(f=>f.scope==='write_reports'&&f.ruleId==='SG-SCOPE-001')); }); test('official alternative read scopes satisfy resource reads',t=>{ - const root=fixture(t,'read_products,read_marketplace_orders');fs.writeFileSync(path.join(root,'reads.graphql'),'query { inventoryItems(first:1){nodes{id}} orders(first:1){nodes{id}} }');assert.ok(!audit({root}).findings.some(f=>f.ruleId==='SG-SCOPE-001')); - fs.writeFileSync(path.join(root,'reads.graphql'),'query { inventoryItems(first:1){nodes{inventoryLevels(first:1){nodes{id}}}} }');assert.ok(audit({root}).findings.some(f=>f.scope==='read_inventory')); + const root=fixture(t,'read_products,read_marketplace_orders');fs.writeFileSync(path.join(root,'reads.ts'),adminDocument('query { inventoryItems(first:1){nodes{id}} orders(first:1){nodes{id}} }'));assert.ok(!audit({root}).findings.some(f=>f.ruleId==='SG-SCOPE-001')); + fs.writeFileSync(path.join(root,'reads.ts'),adminDocument('query { inventoryItems(first:1){nodes{inventoryLevels(first:1){nodes{id}}}} }'));assert.ok(audit({root}).findings.some(f=>f.scope==='read_inventory')); });