update-script.js (169 lines of code) (raw):
const KintoClient = require("kinto-http").default;
const btoa = require("btoa");
const fetch = require("node-fetch");
const AppConstants = require("./app-constants");
const RELATED_REALMS_COLLECTION_ID = "websites-with-shared-credential-backends";
const PASSWORD_RULES_COLLECTION_ID = "password-rules";
/** @type {String} */
/** @type {String} */
const AUTHORIZATION = AppConstants.AUTHORIZATION;
/** @type {String} */
const SERVER_ADDRESS = AppConstants.SERVER;
const BUCKET = "main-workspace";
const SHARED_CREDENTIALS_API_ENDPOINT =
"https://api.github.com/repos/apple/password-manager-resources/contents/quirks/shared-credentials.json";
const SHARED_CREDENTIALS_HISTORICAL_API_ENDPOINT =
"https://api.github.com/repos/apple/password-manager-resources/contents/quirks/shared-credentials-historical.json";
const PASSWORD_RULES_API_ENDPOINT =
"https://api.github.com/repos/apple/password-manager-resources/contents/quirks/password-rules.json";
/**
* Converts the shared-credentials.json and shared-credentials-historical.json from apple/password-manager-resources into the legacy
* format (previously contained in websites-with-shared-credential-backends.json) that firefox expects.
* Converted from ruby script here: https://github.com/apple/password-manager-resources/blob/9917b5c8/tools/convert-shared-credential-to-legacy-format.rb
*/
async function getSharedCredentialsLegacyFormat() {
const credentialEntries = await Promise.all([
getSourceRecords(SHARED_CREDENTIALS_API_ENDPOINT),
getSourceRecords(SHARED_CREDENTIALS_HISTORICAL_API_ENDPOINT),
]);
const legacyOutput = [];
for (const entry of credentialEntries.flat()) {
if (entry.shared) {
legacyOutput.push(entry.shared);
} else if (entry.from && entry.to) {
legacyOutput.push([...entry.from, ...entry.to]);
} else {
console.error("ERROR: Could not convert entry to legacy format.", entry);
}
}
return legacyOutput.sort();
}
/**
* Fetches the source records from the apiEndpoint param
*
* Since this script should run once every two weeks, we don't need a GitHub token.
* See also: https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
* @param {string} apiEndpoint either `RELATED_REALMS_API_ENDPOINT` or `PASSWORD_RULES_API_ENDPOINT`
* @return {String[][]} The source records
*/
const getSourceRecords = async (apiEndpoint) => {
const response = await fetch(apiEndpoint, {
headers: {
"Accept": "application/vnd.github.v3.raw"
}
});
const data = await response.json();
return data;
}
const arrayEquals = (a, b) => {
return Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((val, index) => val === b[index]);
};
/**
* Updates the existing record in the "websites-with-shared-credential-backends" Remote Settings collection with the updated data from Apple's GitHub repository
*
* @param {KintoClient} client KintoClient instance
* @param {string} bucket Name of the Remote Settings bucket
* @param {Object} newRecord Object containing the updated related realms object
* @param {string} newRecord.id ID from the current related realms object from the Remote Settings server
* @param {string[][]} newRecord.relatedRealms Updated related realms array from GitHub
*/
const updateRelatedRealmsRecord = async (client, bucket, newRecord) => {
const cid = RELATED_REALMS_COLLECTION_ID;
await client.bucket(bucket).collection(cid).updateRecord(newRecord);
await client.bucket(bucket).collection(cid).setData({ status: "to-review" }, { patch: true });
console.log(`Found new records, committed changes to ${cid} collection.`);
};
/**
* Creates a new record in Remote Settings if there are no records in the WEBSITES_WITH_SHARED_CREDENTIAL_COLLECTION
*
* @param {KintoClient} client
* @param {string} bucket
*/
const createRelatedRealmsRecord = async (client, bucket, sourceRecords) => {
const cid = RELATED_REALMS_COLLECTION_ID;
const result = await client.bucket(bucket).collection(cid).createRecord({
relatedRealms: sourceRecords
});
await client.bucket(bucket).collection(cid).setData({ status: "to-review" }, { patch: true });
console.log(`Added new record to ${cid}`, result);
};
const printSuccessMessage = () => {
console.log("Script finished successfully!");
}
/**
* Determines if there are new records from the GitHub source for the "websites-with-shared-credential-backends" collection
*
* @param {String[][]} sourceRecords Related realms from Apple's GitHub
* @param {String[][]} destinationRecords Related realms from Remote Settings
* @return {Boolean} `true` if there are new records, `false` if there are no new records
*/
const checkIfNewRelatedRealmsRecords = (sourceRecords, destinationRecords) => {
let areNewRecords = false;
if (sourceRecords.length !== destinationRecords.length) {
areNewRecords = true;
}
for (let i = 0; i < sourceRecords.length; i++) {
if (areNewRecords) {
break;
}
areNewRecords = !arrayEquals(sourceRecords[i], destinationRecords[i]);
}
return areNewRecords;
}
/**
* Converts the records from the "password-rules" Remote Settings collection into a Map
* for easier comparison against the GitHub source of truth records.
*
* @param {Object[]} records
* @param {string} records.Domain
* @param {string} records[password-rules]
* @return {Map}
*/
const passwordRulesRecordsToMap = (records) => {
let map = new Map();
for (let record of records) {
let { id, Domain: domain, "password-rules": rules } = record;
map.set(domain, { id: id, "password-rules": rules });
}
return map;
}
/**
* Creates and/or updates the existing records in the "password-rules" Remote Settings collection with the updated data from Apple's GitHub repository
*
* @param {KintoClient} client KintoClient instance
* @param {string} bucket Name of the Remote Settings bucket
*/
const createAndUpdateRulesRecords = async (client, bucket) => {
let collection = client.bucket(bucket).collection(PASSWORD_RULES_COLLECTION_ID);
let sourceRulesByDomain = await getSourceRecords(PASSWORD_RULES_API_ENDPOINT);
let { data: remoteSettingsRecords } = await collection.listRecords();
debugger;
let remoteSettingsRulesByDomain = passwordRulesRecordsToMap(remoteSettingsRecords);
let batchRecords = [];
for (let domain in sourceRulesByDomain) {
let passwordRules = sourceRulesByDomain[domain]["password-rules"];
let id;
let oldRules;
let _record = remoteSettingsRulesByDomain.get(domain);
if (_record) {
id = _record.id;
oldRules = _record["password-rules"];
}
if (!id) {
let newRecord = { "Domain": domain, "password-rules": passwordRules };
batchRecords.push(newRecord);
console.log("Added new record to batch!", newRecord);
}
if (id && oldRules !== passwordRules) {
let updatedRecord = { id, "Domain": domain, "password-rules": passwordRules };
batchRecords.push(updatedRecord);
console.log("Added updated record to batch!", updatedRecord);
}
}
await collection.batch(batch => {
for (let record of batchRecords) {
if (record.id) {
batch.updateRecord(record);
} else {
batch.createRecord(record);
}
}
});
await collection.setData({ status: "to-review" }, { patch: true });
if (batchRecords.length) {
console.log(`Found new and/or updated records, committed changes to ${PASSWORD_RULES_COLLECTION_ID} collection.`);
} else {
console.log(`Found no new or updated records for the ${PASSWORD_RULES_COLLECTION_ID} collection.`);
}
};
/**
* Creates and/or updates the existing records in the "websites-with-shared-credential-backends" Remote Settings collection
* with the updated data from Apple's GitHub repository.
*
* @param {KintoClient} client
* @param {string} bucket
*/
const createAndUpdateRelatedRealmsRecords = async (client, bucket) => {
let { data: relatedRealmsData } = await client.bucket(bucket).collection(RELATED_REALMS_COLLECTION_ID).listRecords();
let realmsGithubRecords = await getSharedCredentialsLegacyFormat();
let id = relatedRealmsData[0]?.id;
// If there is no ID from Remote Settings, we need to create a new record in the related realms collection
if (!id) {
await createRelatedRealmsRecord(client, bucket, realmsGithubRecords);
} else {
// If there is an ID, we can compare the source and destination records
let currentRecords = relatedRealmsData[0].relatedRealms;
let areNewRecords = checkIfNewRelatedRealmsRecords(realmsGithubRecords, currentRecords);
// If there are new records, we need to update the data of the record using the current ID
if (areNewRecords) {
let newRecord = {
id: id,
relatedRealms: realmsGithubRecords
};
await updateRelatedRealmsRecord(client, bucket, newRecord)
} else {
console.log(`No new records! Not committing any changes to ${RELATED_REALMS_COLLECTION_ID} collection.`);
}
}
};
/**
* The runner for the script.
*
* @return {Number} 0 for success, 1 for failure.
*/
const main = async () => {
if (AUTHORIZATION === "") {
console.error("No username or password set, quitting!");
return 1;
}
try {
const client = new KintoClient(SERVER_ADDRESS, {
headers: {
Authorization: "Basic " + btoa(AUTHORIZATION)
}
});
await createAndUpdateRelatedRealmsRecords(client, BUCKET);
await createAndUpdateRulesRecords(client, BUCKET);
} catch (e) {
console.error(e);
return 1;
}
printSuccessMessage();
return 0;
};
main();