scan, privacy answers and credentials from a second game's first release (lessons 35-37): review-prompt needs rating context, not a bare "rate"; account signals skip import lines and module paths, config-only accounts say "from config"; accounts.inApp without deletion code stays a medium 5.1.1(v) finding; privacy-manifest --propose builds NSPrivacyCollectedDataTypes from appPrivacy.types and check warns on an empty list; appPrivacy.types is authoritative in the App Privacy sheet (unlisted = not collected), ad / support / AR types default to not collected without tracking or ad SDKs; Play advertising ID from SDKs, the AD_ID permission, app-ads.txt or the flag, never from a Device ID alone; scan.ignore[].evidence and warnings for entries that match nothing; empty credentials template, placeholder values count as missing (check, doctor)
This commit is contained in:
parent
86cd79dd45
commit
1366e7e231
34 changed files with 881 additions and 71 deletions
|
|
@ -81,7 +81,7 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
|
|||
detected.set(t, e);
|
||||
};
|
||||
if (accounts) {
|
||||
markAccount('PSL_USER_ACCOUNT', signUp.length ? signUp : [cfgAt(/accounts\s*:/, 'accounts.enabled')], 'accounts');
|
||||
markAccount('PSL_USER_ACCOUNT', signUp.length ? signUp : [cfgAt(/accounts\s*:/, 'from config (accounts.enabled)')], 'accounts');
|
||||
for (const s of signUpCfg) for (const t of ACCOUNT_SIGN_UP[s]?.data ?? []) if (t !== 'PSL_USER_ACCOUNT') markAccount(t, [cfgAt(/signUp\s*:/, `accounts.signUp: ${s}`)], `accounts.signUp ${s}`);
|
||||
const em = grepOwn(p, SIGNALS.email, 2);
|
||||
if (em.hits.length && !em.built) markAccount('PSL_EMAIL', em.hits, 'email in the sources');
|
||||
|
|
@ -147,8 +147,12 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
|
|||
// appPrivacy.types: propose collected + purposes for detected types the config does not answer (Apple apps only)
|
||||
const haveTypes = c?.appPrivacy?.types ?? {};
|
||||
const typesAdded: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {};
|
||||
// appPrivacy.types is authoritative once it exists (a type it does not list is "not collected"), so a block this
|
||||
// proposal writes also carries the types dataSafety already declares (kept) when the config has no types yet
|
||||
const detectedNew = diff.some(d => d.change !== 'kept' && typeIdOf(d.apple.split(' / ')[1]!) && !haveTypes[typeIdOf(d.apple.split(' / ')[1]!)!]);
|
||||
const withKept = detectedNew && !Object.keys(haveTypes).length;
|
||||
if (c?.apple) for (const d of diff) {
|
||||
if (d.change === 'kept') continue;
|
||||
if (d.change === 'kept' && !withKept) continue;
|
||||
const id = typeIdOf(d.apple.split(' / ')[1]!);
|
||||
if (!id || id === 'OTHER_DATA_TYPES' || haveTypes[id]) continue;
|
||||
const shared = d.after.shared ? d.after.share ?? [] : [];
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { reviewDemoAccount } from '../credentials';
|
|||
import { proposeDeclarations, type Proposal } from './declare';
|
||||
import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project';
|
||||
import type { OriginalityReport } from '../originality';
|
||||
import { APPLE_GUIDELINES, type ScanFinding, scanRules, type Severity, sortScan } from './rules';
|
||||
import { APPLE_GUIDELINES, applyIgnore, type ScanFinding, scanRules, type Severity, sortScan, unusedIgnores } from './rules';
|
||||
import type { SdkCategory } from './sdks';
|
||||
|
||||
export interface ScanReport {
|
||||
|
|
@ -68,6 +68,8 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise
|
|||
else if (facts.android && facts.androidInfo.manifests.length) notes.push('Android: libraries add permissions when the manifest is merged and no local build output (build/intermediates) was found; check the merged manifest (Android Studio > Merged Manifest, or the AAB) too.');
|
||||
if (facts.ios && !facts.plistSources.length) notes.push('iOS: no Info.plist settings found (generated at build time?): purpose string, ATS and export compliance checks were skipped.');
|
||||
if (!facts.sources.length) notes.push('No app source files found: code checks (sign-in, deletion, restore, ATT prompt) could not run.');
|
||||
// scan.ignore entries that accept nothing (a renamed title, a typo, a fixed issue) are said, never silently dropped
|
||||
notes.push(...unusedIgnores(findings, config?.scan?.ignore ?? []));
|
||||
const report: ScanReport = {
|
||||
root: dir, config: configPath ? rel(configPath) : null, configError: error,
|
||||
stacks: facts.stacks, platforms: { ios: facts.ios, android: facts.android },
|
||||
|
|
@ -149,10 +151,9 @@ export function addOriginality(r: ScanReport, o: OriginalityReport, stores: { ap
|
|||
if (stores.apple) r.findings.push({ ...base, guideline: '4.3', store: 'apple', ref: APPLE_GUIDELINES });
|
||||
if (stores.play) r.findings.push({ ...base, guideline: 'Play: Repetitive content', store: 'play', ref: 'https://support.google.com/googleplay/android-developer/answer/9898783' });
|
||||
}
|
||||
for (const f of r.findings) if (f.rule === 'originality' && !f.accepted) {
|
||||
const hit = ignore.find(i => i.rule === f.rule && (!i.store || i.store === f.store) && (!i.match || f.title.toLowerCase().includes(i.match.toLowerCase())));
|
||||
if (hit) f.accepted = hit.reason;
|
||||
}
|
||||
applyIgnore(r.findings.filter(f => f.rule === 'originality'), ignore);
|
||||
// runScan skipped the originality entries (it cannot produce that rule); now they can be checked
|
||||
r.notes.push(...unusedIgnores(r.findings, ignore, []).filter(n => n.includes('(rule originality')));
|
||||
r.findings = sortScan(r.findings);
|
||||
r.counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
|
||||
for (const f of r.findings) { if (f.accepted) r.counts.accepted++; else r.counts[f.severity]++; }
|
||||
|
|
|
|||
|
|
@ -85,16 +85,24 @@ export const PLAY_TARGET_SDK = { phone: 36, wear: 35, automotive: 35, tv: 34 } a
|
|||
export const PLAY_TARGET_SDK_SINCE = '2026-08-31';
|
||||
|
||||
// ---- code signals ----
|
||||
/** `re` only on lines that are not import / require / export-from statements (a module path is not evidence). Line
|
||||
* anchored with the m flag, so a whole-file test and a per-line test agree. */
|
||||
export function codeLine(re: RegExp): RegExp {
|
||||
return new RegExp(`^(?!\\s*import[\\s{*'"])(?!.*\\b(?:require|import)\\s*\\(\\s*['"\`])(?!.*\\bfrom\\s*['"\`]).*?(?:${re.source})`, `${re.flags.replace('m', '')}m`);
|
||||
}
|
||||
export const SIGNALS = {
|
||||
appleSignIn: /ASAuthorizationAppleIDProvider|SignInWithAppleButton|AppleAuthenticationButton|AppleAuthentication\.signInAsync|appleAuth\.performRequest|SignInWithApple|OAuthProvider\(\s*['"]apple\.com|AppleAuthProvider|signInWithApple|AppleAuthManager/,
|
||||
thirdPartyLogin: /GIDSignIn|GoogleSignin\.signIn|GoogleSignIn\(|GoogleAuthProvider|signInWithGoogle|FacebookAuthProvider|LoginManager\(\)\.logIn|LoginManager\.logInWithPermissions|signInWithFacebook|FacebookLogin|TwitterAuthProvider|GithubAuthProvider|OAuthProvider\(\s*['"](microsoft\.com|yahoo\.com|twitter\.com|github\.com)/,
|
||||
signIn: /signInWithEmailAndPassword|signInWithPassword|signInWithCredential|createUserWithEmailAndPassword|\bsupabase\.auth\.sign|Amplify\.Auth\.signIn|\bAuth0\b|\bloginWithPassword\b|\.signIn\(\s*\{|\/(api\/)?(auth\/)?(login|signin|sign-in)\b['"`]/,
|
||||
signUp: /createUserWithEmailAndPassword|\bsignUp\s*\(|\bsupabase\.auth\.signUp|Amplify\.Auth\.signUp|\/(api\/)?(auth\/)?(register|signup|sign-up)\b['"`]|["'>](Create (an )?account|Sign up)["'<]/,
|
||||
// sign-in / sign-up: auth SDK calls, credential fields, guest-account creation, auth endpoints (an absolute path, a
|
||||
// URL or a `${base}/...` template, never a module path like './buildings/register') and screen strings. Import /
|
||||
// require lines never count: a module named register, login or account is not an auth flow.
|
||||
signIn: codeLine(/signInWithEmailAndPassword|signInWithPassword|signInWithCredential|createUserWithEmailAndPassword|\bsupabase\.auth\.sign|Amplify\.Auth\.signIn|\bAuth0\b|\bloginWithPassword\b|\.signIn\(\s*\{|autoComplete=["']current-password|textContentType=["']?\.?password\b|\btype=["']password["']|['"`](https?:\/\/[^'"`\s]*|\$\{[^}]*\})?\/(api\/|v\d+\/)*(auth\/|users?\/|accounts?\/)?(login|signin|sign-in)['"`?]/),
|
||||
signUp: codeLine(/createUserWithEmailAndPassword|\bsignUp\s*\(|\bsupabase\.auth\.signUp|Amplify\.Auth\.signUp|autoComplete=["']new-password|textContentType=["']?\.?newPassword\b|\bcreate(Guest|Anonymous)(Account|User)\b|\bregisterGuest\b|['"`](https?:\/\/[^'"`\s]*|\$\{[^}]*\})?\/(api\/|v\d+\/)*(auth\/|users?\/|accounts?\/)?(register|signup|sign-up)['"`?]|["'>](Create (an )?account|Sign up)["'<]/),
|
||||
// real evidence that the app collects an email address: an email input or keyboard, an `email` property in a request
|
||||
// body / schema / type (not a quoted constant or label), an email validation, an email endpoint or an email sign-in
|
||||
// call. Accounts alone never mean email (a username with a device token has none).
|
||||
email: /type\s*[:=]\s*["'{]*email["'}]|inputMode=["']email["']|keyboardType=["']?email-address|autoComplete=["']email|textContentType=["']?(emailAddress|\.emailAddress)|UITextContentType\.emailAddress|\.keyboardType\(\.emailAddress\)|keyboardType\s*=\s*\.emailAddress|TextInputType\.emailAddress|TYPE_TEXT_VARIATION_EMAIL_ADDRESS|KeyboardType\.Email\b|(?<![\w.$-])["']?email["']?\s*\??:\s*(?![\s"'`]|$)|z\.string\(\)(\.\w+\([^)]*\))*\.email\(|\bisEmail\(|\[\^\\s@\]\+@|\[a-zA-Z0-9._%+-\]\+@|\/(api\/)?(auth\/)?(email|verify-email|forgot-password|reset-password|magic-link)\b['"`]|signInWithEmailAndPassword|createUserWithEmailAndPassword|sendSignInLinkToEmail|createUser\(withEmail:|signIn\(withEmail:|sendSignInLink\(toEmail:|signInWithOtp\(\s*\{\s*email|ASAuthorization\.Scope\.email|requestedScopes[^\n]{0,40}\.email\b/,
|
||||
deleteAccount: /deleteUser\b|\buser\.delete\(|currentUser\??\.delete\(|\bdeleteAccount\b|delete_account|DeleteAccount|delete-account|["'>]Delete (my |your )?account|\brevokeToken\b|auth\.admin\.deleteUser|Hesab(ı|ımı) sil/i,
|
||||
deleteAccount: codeLine(/deleteUser\b|\buser\.delete\(|currentUser\??\.delete\(|\bdeleteAccount\b|delete_account|DeleteAccount|delete-account|["'>]Delete (my |your )?account|\brevokeToken\b|auth\.admin\.deleteUser|Hesab(ı|ımı) sil/i),
|
||||
restore: /restoreCompletedTransactions|AppStore\.sync\(|restorePurchases|restoreTransactions|RestoreTransactions|getAvailablePurchases|\brestorePurchase\b|\.restore\(\)|Restore Purchases|syncPurchases|Purchases\.shared\.restore|store\.restore\b|queryPurchasesAsync/i,
|
||||
subscriptionCode: /subscriptionPeriod|Product\.SubscriptionInfo|\.autoRenewable\b|entitlements\.active|SubscriptionStoreView|ProductType\.Subscription|ProductType\.SUBS|BillingClient\.ProductType\.SUBS/,
|
||||
attRequest: /requestTrackingAuthorization|requestTrackingPermissionsAsync|requestTrackingPermission\b|AppTrackingTransparency\.requestTrackingAuthorization|ATTrackingStatusBinding\.RequestAuthorizationTracking|TrackingTransparency\.requestPermission/,
|
||||
|
|
@ -108,7 +116,13 @@ export const SIGNALS = {
|
|||
privateScheme: /["'`](App-Prefs|prefs):root\b/,
|
||||
ratePrompt: /["'`>](Rate (us|this app)|Enjoying [A-Z][\w ]{0,30}\?|Love (this|the) app\?)/,
|
||||
reviewApi: /SKStoreReviewController|requestReview|StoreReview\.requestReview|InAppReview|in_app_review|react-native-rate|requestReviewFlow|ReviewManager/,
|
||||
incentiveRating: /(?<![\w-])(rate|review)(?![\w-])(?!\s+limit)[^"'`\n]{0,60}\b(rewards?|rewarded|coins?|gems?|bonus|free (lives|spins|coins))\b|(reward|coins?|gems?|bonus)\b[^"'`\n]{0,40}\b(for|to) (rating|reviewing|rate|review)\b|5[- ]?stars?\b[^"'`\n]{0,40}\b(reward|get|earn)/i,
|
||||
// a reward tied to rating: a "rate us / rate this app / leave a review" phrase next to a reward word, or a reward
|
||||
// "for rating / reviewing". A bare "rate" or "review" is never enough (a production rate, frame rate, exchangeRate,
|
||||
// rate limit or code review next to "bonus" is not a review prompt).
|
||||
incentiveRating: /\b(rate|review) (us|me|this (app|game)|the (app|game)|our (app|game))\b[^\n]{0,60}?\b(rewards?|rewarded|coins?|gems?|bonus|free (lives|spins|coins))\b|\b(leave|write|give) (us |me )?(a )?(review|rating|(5|five)[- ]?stars?)\b[^\n]{0,60}?\b(rewards?|rewarded|coins?|gems?|bonus|free (lives|spins|coins))\b|\b(rewards?|coins?|gems?|bonus)\b[^"'`\n]{0,40}\b(for|to|by) (rating|reviewing|(rate|review) (us|me|the|this|our)\b|leaving a review|writing a review)/i,
|
||||
// a reward granted next to a system review prompt call (checked within a few lines by rewardNearReviewApi)
|
||||
reviewApiCall: /SKStoreReviewController\.requestReview|\brequestReview\s*\(|requestReviewFlow\s*\(|launchReviewFlow\s*\(|InAppReview\.(instance\.)?requestReview|StoreReview\.requestReview|\.requestReview\(\)/,
|
||||
rewardGrant: /\b(add|give|grant|award)(Coins?|Gems?|Reward|Bonus|Currency|Lives)\b|\b(reward|bonus)\s*\(|\bcoins\s*\+=|\bgems\s*\+=|\bgrantReward\b/i,
|
||||
externalBuy: /\b(buy|subscribe|purchase|upgrade|pay)\b[^.\n]{0,40}\b(on|at|via|from) (our )?(website|web ?site|web|site)\b/i,
|
||||
};
|
||||
|
||||
|
|
@ -128,6 +142,22 @@ const FGS_PERMISSION: Record<string, string> = {
|
|||
};
|
||||
const SMS_CALL_LOG = ['READ_SMS', 'RECEIVE_MMS', 'RECEIVE_SMS', 'RECEIVE_WAP_PUSH', 'SEND_SMS', 'WRITE_SMS', 'READ_CALL_LOG', 'WRITE_CALL_LOG', 'PROCESS_OUTGOING_CALLS'];
|
||||
|
||||
/** A system review prompt call with a reward grant within `win` lines of it (rewarding the rating): the call line. */
|
||||
export function rewardNearReviewApi(sources: { path: string; text: string }[], max = 2, win = 6): Hit[] {
|
||||
const out: Hit[] = [];
|
||||
for (const s of sources) {
|
||||
if (!SIGNALS.reviewApiCall.test(s.text) || !SIGNALS.rewardGrant.test(s.text)) continue;
|
||||
const lines = s.text.split('\n');
|
||||
lines.forEach((l, i) => {
|
||||
if (out.length >= max || !SIGNALS.reviewApiCall.test(l) || /^\s*(\/\/|#|\*)/.test(l)) return;
|
||||
if (lines.slice(Math.max(0, i - win), i + win + 1).some(x => SIGNALS.rewardGrant.test(x) && !/^\s*(\/\/|#|\*)/.test(x)))
|
||||
out.push({ file: s.path, line: i + 1, text: l.trim().slice(0, 160) });
|
||||
});
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Text lines (inside fenced blocks of a listing file, or JSON string values) that match `re`. */
|
||||
function listingHits(file: { path: string; text: string } | undefined, re: RegExp, o: { fenced?: boolean; field?: RegExp } = {}): Hit[] {
|
||||
if (!file) return [];
|
||||
|
|
@ -172,10 +202,12 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
const authSdk = sdkHits(s => s.sdk.category === 'auth');
|
||||
const signInCode = src(SIGNALS.signIn, 2);
|
||||
const signUpCode = src(SIGNALS.signUp, 2);
|
||||
const accountsCfg = c?.accounts?.enabled ? cfg(/accounts\s*:/) : [];
|
||||
const accountsCfg = c?.accounts?.enabled ? cfg(/accounts\s*:/, 'from config (accounts.enabled)') : [];
|
||||
const signIn = [...accountsCfg, ...authSdk, ...signInCode];
|
||||
const creates = [...accountsCfg, ...signUpCode, ...thirdParty, ...siwa, ...sdkHits(s => s.sdk.login === 'firebase')];
|
||||
const deletion = [...(c?.accounts?.inApp ? cfg('inApp') : []), ...src(SIGNALS.deleteAccount, 2)];
|
||||
// in-app deletion needs code evidence (a delete-account call, function or UI string): accounts.inApp alone is a claim
|
||||
const deletionCode = src(SIGNALS.deleteAccount, 2);
|
||||
const deletionClaim = c?.accounts?.inApp ? cfg('inApp', `from config (accounts.inApp: ${c.accounts.inApp})`) : [];
|
||||
|
||||
if (signIn.length) {
|
||||
const ra = c?.reviewAccess;
|
||||
|
|
@ -193,7 +225,11 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
fix: 'Play Console > App content > App access: add the demo account and instructions (keep the credentials in .storebound/credentials.json only).', skill: 'review-access', ref: `${PLAY}9859455` });
|
||||
}
|
||||
if (creates.length) {
|
||||
if (apple && !deletion.length)
|
||||
if (apple && !deletionCode.length && deletionClaim.length)
|
||||
add({ rule: 'account-deletion', guideline: '5.1.1(v)', store: 'apple', severity: 'medium',
|
||||
title: 'In-app account deletion is claimed by config (accounts.inApp), not found in code', evidence: [...deletionClaim, ...creates].slice(0, 4),
|
||||
fix: 'Show the code path or the screen: a delete-account call (endpoint, deleteUser / user.delete()), a deletion function or a "Delete account" UI string in the app sources. If deletion lives behind a server-driven or web screen, check it on a device and accept this finding with scan.ignore (a reason). A config claim alone does not pass App Review.', skill: 'account-deletion' });
|
||||
else if (apple && !deletionCode.length)
|
||||
add({ rule: 'account-deletion', guideline: '5.1.1(v)', store: 'apple', severity: accountsCfg.length || signUpCode.length ? 'blocker' : 'high',
|
||||
title: 'Accounts can be created but no in-app account deletion was found', evidence: creates.slice(0, 4),
|
||||
fix: 'Add "Delete account" in the app (it deletes the account and its data, not only signs out; with Sign in with Apple also revoke the token), then set accounts.inApp to where it starts, e.g. "Settings > Account > Delete account".', skill: 'account-deletion' });
|
||||
|
|
@ -511,7 +547,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
if (priv.length)
|
||||
add({ rule: 'private-api', guideline: '2.5.1', store: 'apple', severity: 'high', title: 'Private settings URL scheme (App-Prefs: / prefs:root)', evidence: priv,
|
||||
fix: 'Open your own settings page with UIApplication.openSettingsURLString instead.', skill: 'review-rejections' });
|
||||
const incent = grep(p.sources, SIGNALS.incentiveRating, 2);
|
||||
const incent = [...grep(p.sources, SIGNALS.incentiveRating, 2), ...rewardNearReviewApi(p.sources, 2)].slice(0, 2);
|
||||
if (incent.length)
|
||||
add({ rule: 'review-prompt', guideline: '3.2.2(x) / 5.6.3', store: 'apple', severity: 'high', title: 'A reward for rating or reviewing the app', evidence: incent,
|
||||
fix: 'Never reward or gate ratings; ask with the system review prompt only.', skill: 'review-replies' });
|
||||
|
|
@ -530,12 +566,34 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
playRules(inp, { add: f => add({ ...f, store: 'play' }), cfg, sdkHits, listingHits, creates, restorable, paySdk });
|
||||
|
||||
// ---- owner-accepted findings ----
|
||||
const ignore = c?.scan?.ignore ?? [];
|
||||
for (const f of out) {
|
||||
const hit = ignore.find(i => i.rule === f.rule && (!i.store || i.store === f.store) && (!i.match || f.title.toLowerCase().includes(i.match.toLowerCase())));
|
||||
applyIgnore(out, c?.scan?.ignore ?? []);
|
||||
return sortScan(out);
|
||||
}
|
||||
|
||||
export type ScanIgnore = NonNullable<NonNullable<ShipConfig['scan']>['ignore']>[number];
|
||||
|
||||
/** Whether a `scan.ignore` entry accepts a finding: the same rule (and store, when given), `match` in the title and
|
||||
* `evidence` in one evidence line ("file:line text"), both case-insensitive. */
|
||||
export function ignoreMatches(i: ScanIgnore, f: ScanFinding): boolean {
|
||||
if (i.rule !== f.rule || (i.store && i.store !== f.store)) return false;
|
||||
if (i.match && !f.title.toLowerCase().includes(i.match.toLowerCase())) return false;
|
||||
const ev = i.evidence?.toLowerCase();
|
||||
return !ev || f.evidence.some(e => `${e.file}:${e.line} ${e.text}`.toLowerCase().includes(ev));
|
||||
}
|
||||
|
||||
/** Marks the findings a `scan.ignore` entry accepts (the first matching entry's reason). */
|
||||
export function applyIgnore(findings: ScanFinding[], ignore: ScanIgnore[]): void {
|
||||
for (const f of findings) if (!f.accepted) {
|
||||
const hit = ignore.find(i => ignoreMatches(i, f));
|
||||
if (hit) f.accepted = hit.reason;
|
||||
}
|
||||
return sortScan(out);
|
||||
}
|
||||
|
||||
/** `scan.ignore` entries that accept no finding (a typo in the rule id, a title or evidence that changed, a fixed
|
||||
* issue): each as a warning line. `skipRules`: rules this run could not produce (originality without --originality). */
|
||||
export function unusedIgnores(findings: ScanFinding[], ignore: ScanIgnore[], skipRules: string[] = ['originality']): string[] {
|
||||
return ignore.flatMap((i, n) => skipRules.includes(i.rule) || findings.some(f => ignoreMatches(i, f)) ? []
|
||||
: [`scan.ignore[${n}] (rule ${i.rule}${i.store ? `, store ${i.store}` : ''}${i.match ? `, match "${i.match}"` : ''}${i.evidence ? `, evidence "${i.evidence}"` : ''}) matches no finding: check the rule id, the title text (match) and the evidence text (evidence), or remove it if the issue is fixed`]);
|
||||
}
|
||||
|
||||
const RANK: Record<Severity, number> = { blocker: 0, high: 1, medium: 2, low: 3 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue